Skip to main content

Remote Progress & Background Task

Remote Progress allows you to start a long-running task on the server side, quickly free up the HTTP request, then track its progress in Blazor via SignalR. On the server side, the central service is BackgroundTaskService. On the client side, RemoteTaskService creates the HTTP call, opens the hub connection, and exposes a RemoteTaskState containing the progress.

This mechanism avoids timeouts on long-running tasks while maintaining real-time user feedback.

How it works & prerequisites​

Packages and services​

On the client side, add the Lagoon.UI.RemoteTasks package, then register the service:

Registry.cs
builder.Services.AddScoped<RemoteTaskService>();

On the server side, enable background tasks:

Registry.cs
services.AddBackgroundTasks();

The actual extension method is:

public static IServiceCollection AddBackgroundTasks(this IServiceCollection services)

It adds SignalR with the MessagePack protocol and registers BackgroundTaskService as a singleton. The Remote Task hub is automatically mapped by the Lagoon fallback when the service is registered.

Execution principle​

  1. The client calls RemoteTaskService.TryPostAsync(...).
  2. The client service initializes the SignalR connection, generates a task identifier, and adds TaskId / HubCoId to the HTTP request.
  3. The server controller calls BackgroundTaskService.RunTask(...).
  4. The task runs in a new dependency scope.
  5. The server sends Progress changes to the client.
  6. At the end, the server returns the result or error message to the RemoteTaskState.

Simple usage​

Start a server task​

BackgroundTaskService exposes the following public signatures:

public void RunTask<TService, TIn, TResult>(ControllerBase controller, Func<TService, TIn, Progress, CancellationToken, Task<TResult>> func, TIn arg)
public void RunTask<TService, TResult>(ControllerBase controller, Func<TService, Progress, CancellationToken, Task<TResult>> func)
public void RunTask<TService>(ControllerBase controller, Func<TService, Progress, CancellationToken, Task> func)
warning

The method passed to RunTask must be static. If you pass a parameter (TIn), it must not reference a scoped service, because the background task runs in a different scope than the HTTP request.

Example without result:

ImportsController.cs
[ApiController]
[Route("api/imports")]
public class ImportsController : LgControllerBase
{
private readonly BackgroundTaskService _backgroundTaskService;

public ImportsController(BackgroundTaskService backgroundTaskService)
{
_backgroundTaskService = backgroundTaskService;
}

[HttpPost("orders")]
public void ImportOrders(ImportOrdersVm model)
{
// Starts the task in a dedicated server scope and immediately terminates the HTTP request.
_backgroundTaskService.RunTask<ImportService, string, int>(this, ImportOrdersAsync, model.ImportId);
}

private static async Task<int> ImportOrdersAsync(ImportService service, string importId, Progress progress, CancellationToken cancellationToken)
{
progress.Reset(100, "#ImportStarting");

for (int index = 0; index < 100; index++)
{
cancellationToken.ThrowIfCancellationRequested();

// Executes a business step then notifies the client of the progress.
await service.ImportNextOrderAsync(importId, cancellationToken);
progress.Increment(1, "#ImportInProgress");
}

progress.ReportEnd("#ImportFinished");
return 100;
}
}

Track progress (Remote Progress)​

On the client side, RemoteTaskService exposes:

public Task<RemoteTaskState> TryPostAsync<TArg>(string requestUri, TArg model)
public Task<RemoteTaskState> TryPostAsync<TArg>(string requestUri, TArg model, Func<RemoteTaskState, Task> onEnd)
public async Task<RemoteTaskState<TResult>> TryPostAsync<TArg, TResult>(string requestUri, TArg model, Func<RemoteTaskState<TResult>, Task> onEnd)

RemoteTaskState exposes notably:

public string ErrorMessage { get; }
public bool HasError { get; }
public Progress Progress { get; }
public void ThrowIfErrorOccured();
public void Cancel();

RemoteTaskState<TResult> adds:

public TResult Result { get; }

Example of a client screen with progress bar display:

ImportOrdersPage.razor
@inherits LgPage

<LgButton Text="#StartImport" OnClick="LaunchImportAsync" />

@if (_state is not null)
{
<LgLoaderView Progress="_state.Progress" ProgressType="ProgressType.Bar">
<LgLabel Text="#ImportDone" />
</LgLoaderView>
<LgButton Text="#CancelImport" Kind="ButtonKind.Secondary" OnClick="CancelImportAsync" />
}
ImportOrdersPage.razor.cs
public partial class ImportOrdersPage : LgPage
{
[Inject]
public RemoteTaskService RemoteTaskService { get; set; }

private RemoteTaskState<int> _state;

private async Task LaunchImportAsync(ActionEventArgs args)
{
try
{
ImportOrdersVm model = new()
{
ImportId = "orders-2024"
};

// Starts the HTTP call then keeps the state to display the progress.
_state = await RemoteTaskService.TryPostAsync<ImportOrdersVm, int>("api/imports/orders", model, OnImportEndedAsync);
}
catch (Exception ex)
{
ShowException(ex);
}
}

private Task CancelImportAsync(ActionEventArgs args)
{
// Requests cancellation on the server side via the Remote Task hub.
_state?.Cancel();
return Task.CompletedTask;
}

private Task OnImportEndedAsync(RemoteTaskState<int> state)
{
try
{
// Converts any server error into a Lagoon user exception.
state.ThrowIfErrorOccured();
ShowSuccess("#ImportFinished");
}
catch (Exception ex)
{
ShowException(ex);
}
return Task.CompletedTask;
}
}

Progress transits through Progress. The most useful public methods are:

public void Reset(string message = null)
public void Reset(int endPosition, string message = null)
public void Reset(int startPosition, int endPosition, string message = null)
public void Increment()
public void Increment(string message)
public void Increment(int value)
public void Increment(int value, string message)
public void Report(int position)
public void Report(string message)
public void Report(int position, string message)
public void ReportEnd()
public void ReportEnd(string message)
public Progress OpenSubProgress(int subPartLength, bool ignoreMessages = false)
public Progress OpenSubProgress(int subPartLength, string message, bool ignoreMessages = false)
public Progress OpenSubProgress(int subPartLength, int startPosition, int endPosition, bool ignoreMessages = false)
public Progress OpenSubProgress(int subPartLength, int startPosition, int endPosition, string message, bool ignoreMessages = false)
public static Progress operator ++(Progress progress)

BackgroundTaskProgress automatically relays Reset, Report, Increment, and ReportEnd to the client. Duplicate percentage/message pairs are filtered to avoid unnecessary SignalR notifications.

Advanced usage​

Return a result​

Use the RunTask<TService, TIn, TResult> signature on the server side and TryPostAsync<TArg, TResult> on the client side. The result is available in RemoteTaskState<TResult>.Result when the completion callback is called.

ImportsController.cs
[HttpPost("preview")]
public void BuildPreview(ImportOrdersVm model)
{
// Returns the number of parsed rows to the client at the end of the task.
_backgroundTaskService.RunTask<ImportService, string, int>(this, BuildPreviewAsync, model.ImportId);
}

private static async Task<int> BuildPreviewAsync(ImportService service, string importId, Progress progress, CancellationToken cancellationToken)
{
progress.Reset(10, "#PreviewStarting");
int count = await service.BuildPreviewAsync(importId, progress, cancellationToken);
progress.ReportEnd("#PreviewFinished");
return count;
}

Cancellation​

On the client side, call:

_state.Cancel();

On the server side, the task method receives a CancellationToken. Check it regularly:

cancellationToken.ThrowIfCancellationRequested();

Errors​

If the server task fails, BackgroundTaskService captures the exception, logs the error, and sends an error message to the client. In the completion callback, use the actual method:

state.ThrowIfErrorOccured();

You can also check:

state.HasError
state.ErrorMessage

Reconnection and cleanup​

In case of SignalR reconnection, the client requests the current state of each tracked task. If the task is complete, the server returns its result; if it no longer exists, an error message is returned. Once the result is received, the client asks the server to free the result stored in memory.

See also​