How to Build an Asynchronous AI-Content Review Workflow in C#
Learn how to accept image submissions properly and evaluate them for AI content in the background, keeping unapproved content out of normal publication workflows.
Join the DZone community and get the full member experience.
Join For FreeFor application users, waiting for an upload to finish entering a web portal is familiar enough. Waiting again while the application analyzes that upload, however, can leave a user wondering whether anything is happening at all. The application can reassure the user by confirming the upload was received before analysis finishes, but it still needs a way to prevent the uploaded content from being published or used downstream until it's been evaluated against content policies and approved.
We can accomplish this for a user-facing upload application by simply separating the submission process from the evaluation process. That allows the application to save the upload in private staging and queue it for background analysis before returning information to the frontend. With this approach, the uploaded content remains unavailable to downstream workflows until after the application records a content approval decision.
Let's consider, for example, an editorial portal where photographers can submit their images for some publication. By rule, the publication categorically rejects AI-generated content, so it keeps each upload in private staging while its AI-image detector evaluates photo content in the background. When images meet the portal's policy, they can proceed to the publication workflow; when they don't, they must remain on hold for an editor to carefully review.
In this article, we’ll explore building this workflow in C# around an actual AI-generated content detection service, and we'll use the above "editorial portal" concept as a concrete implementation example. We’ll include private staging, a queue, a background worker calling our external image-analysis service, and a recorded decision. The upload endpoint will ultimately return a submission ID to the frontend, which will then use that ID behind the scenes to retrieve the current upload status and display updates beside the uploaded image. Users will then be able to see whether their submission is processing, approved, or awaiting review without ever handling the ID themselves. With this implementation, both the user and the application get exactly what they need.
Separating Submission From Evaluation
We’ll create a clear divide in our processing workflow between an upload endpoint and a background worker. The endpoint will save the image and queue its submission ID; the worker will retrieve that ID, open the saved image, and request an assessment.
This type of separation means the upload response can be returned before detection actually finishes. If we were to await detection inside the endpoint, we would still leave the client waiting for that result. ASP.NET Core provides BackgroundService for implementing the worker, so we’ll take advantage of that in our code.
As we’ve described, the frontend will retain the returned ID and use it to retrieve status automatically. The actual user who submitted the image will be able to see “Processing” next to their upload without handling the response ID manually.
Just note that the .NET 10 excerpts we’ll use assume an existing authenticated application with some form of upload validation in place. We’ll specifically demonstrate the processing components and their integration points.
Creating a Shared Submission Record
Right off the bat, we know the endpoint and worker need a shared record that connects an image to its current status. We’ll create this relationship with an immutable record and a concurrent dictionary for our single-instance demonstration:
using System.Collections.Concurrent;
public enum SubmissionStatus
{
Pending,
Processing,
Approved,
AwaitingReview,
Unverified
}
public sealed record DetectionResult(
bool? CleanResult,
double? AiGeneratedRiskScore,
string? AiSource);
public sealed record Submission(
Guid Id,
string OwnerId,
string FilePath,
SubmissionStatus Status,
DateTimeOffset UpdatedAt,
DetectionResult? Detection = null);
public sealed class SubmissionStore
{
public ConcurrentDictionary<Guid, Submission> Items { get; } = new();
}
Here, the endpoint creates a Pending record, and the worker replaces it with updated copies as evaluation progresses. This lets status requests read complete snapshots.
The AwaitingReview status identifies flagged content, while Unverified identifies an unusable or failed assessment. Approved means the image passed this image-origin policy (not every possible publication or security check; we’ll assume there are several others).
Creating the Queue
Now we’ll create the actual connection between the endpoint and the worker. A bounded Channel<Guid> holds submission IDs until the worker reads them:
using System.Threading.Channels;
public sealed class ReviewQueue
{
private readonly Channel<Guid> channel =
Channel.CreateBounded<Guid>(new BoundedChannelOptions(100)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait
});
public bool TryEnqueue(Guid id) =>
channel.Writer.TryWrite(id);
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken ct) =>
channel.Reader.ReadAllAsync(ct);
}
Note that in our example, we’ve allowed 100 waiting jobs. Wait mode prevents existing jobs from being discarded, while TryWrite immediately returns false when capacity is completely exhausted. That allows our endpoint to reject the submission instead of sitting around waiting for it indefinitely.
Saving and Scheduling the Upload
Within our existing upload handler, we’ll first enforce file-size and supported-format checks (this rejects invalid or potentially unsafe files before they're stored or processed).
To prevent users from guessing filenames or accessing uploads directly, it’s important that we save the image under a server-generated filename in a private directory.
After validation, the below code saves image, an IFormFile. The configured staging directory is privateDirectory, and ownerId comes from the authenticated identity:
Directory.CreateDirectory(privateDirectory);
var id = Guid.NewGuid();
var path = Path.Combine(privateDirectory, id.ToString("N"));
var fileCreated = false;
try
{
await using var output = new FileStream(
path, FileMode.CreateNew, FileAccess.Write);
fileCreated = true;
await image.CopyToAsync(output, cancellationToken);
}
catch
{
if (fileCreated)
File.Delete(path);
throw;
}
store.Items[id] = new Submission(
id,
ownerId,
path,
SubmissionStatus.Pending,
DateTimeOffset.UtcNow);
if (!queue.TryEnqueue(id))
{
store.Items.TryRemove(id, out _);
File.Delete(path);
return Results.StatusCode(
StatusCodes.Status503ServiceUnavailable);
}
return Results.Accepted(
$"/submissions/{id}",
new { Id = id, Status = "Pending" });
Note that this excerpt uses injected SubmissionStore store and ReviewQueue queue instances; privateDirectory must be an absolute path outside publicly served directories.
We’ve designed this so the file is closed before enqueueing, which allows the worker to reopen it. If scheduling fails for some reason, we simply remove the unscheduled record and file.
The 202 Accepted response is what confirms acceptance for processing; its location points the frontend to the status endpoint we’ll implement below.
Implementing the Detection Call
We’ll now put the remote request in a dedicated Detector class. This gives the worker exactly one operation to invoke — DetectAsync(path, cancellationToken).
We could theoretically use a hosted classifier for the same role, with our team managing its model and infrastructure. In this case, the implementation calls our AI image-detection endpoint using multipart form data.
using System.Net.Http.Json;
public sealed class Detector(
IHttpClientFactory clients,
IConfiguration configuration)
{
public async Task<DetectionResult?> DetectAsync(
string path, CancellationToken ct)
{
var key = configuration["Cloudmersive:ApiKey"];
if (string.IsNullOrWhiteSpace(key))
throw new InvalidOperationException(
"Configure the Cloudmersive API key.");
using var client = clients.CreateClient("Cloudmersive");
using var file = File.OpenRead(path);
using var form = new MultipartFormDataContent();
form.Add(
new StreamContent(file),
"imageFile",
"submission");
using var request = new HttpRequestMessage(
HttpMethod.Post, "image/ai-detection/file");
request.Headers.Add("Apikey", key);
request.Content = form;
using var response = await client.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<DetectionResult>(
cancellationToken: ct);
}
}
Note that the API key here comes from server-side configuration. The method just opens the staged image, submits its bytes, and then deserializes the response on the other end. Any HTTP failures cause an exception for the worker to handle.
If you happened to read my previous article on AI detection, you’ll know that CleanResult supplies the summarized classification for this service, and AiGeneratedRiskScore provides the risk signal we’re looking for. AiSource then adds optional source context (this is information about the exact AI model that created the image; it’s not always available, even when images are deemed high-probability AI). The key thing to note is that we’re working with probabilistic signals rather than definitive proof of authorship.
Turning the Assessment into a Decision
We’ll now implement the decision portion separately so the worker can apply the same rule to every response:
public static class ReviewPolicy
{
public static SubmissionStatus Evaluate(DetectionResult? result)
{
if (result?.AiGeneratedRiskScore is not double score ||
!double.IsFinite(score) ||
score < 0 || score > 1 ||
result.CleanResult is null)
{
return SubmissionStatus.Unverified;
}
// Illustrative application threshold.
const double reviewThreshold = 0.5;
return result.CleanResult == false || score > reviewThreshold
? SubmissionStatus.AwaitingReview
: SubmissionStatus.Approved;
}
}
In this example, we approve usable results with a clean classification and an AI-generated-probability score no higher than 0.5.
Processing Jobs and Saving Outcomes
At this point, we can connect all the pieces. Our worker will read an ID, load its record, mark it as “processing,” and call Detector. After that, it'll replace the record with the response and policy decision:
public sealed class ReviewWorker(
ReviewQueue queue,
SubmissionStore store,
Detector detector,
ILogger<ReviewWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
try
{
await foreach (var id in queue.ReadAllAsync(ct))
{
if (!store.Items.TryGetValue(id, out var item))
continue;
store.Items[id] = item with
{
Status = SubmissionStatus.Processing,
UpdatedAt = DateTimeOffset.UtcNow
};
try
{
var result = await detector.DetectAsync(
item.FilePath, ct);
store.Items[id] = item with
{
Status = ReviewPolicy.Evaluate(result),
Detection = result,
UpdatedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
store.Items[id] = item with
{
Status = SubmissionStatus.Unverified,
UpdatedAt = DateTimeOffset.UtcNow
};
if (ct.IsCancellationRequested)
break;
logger.LogError(
ex, "Evaluation failed for {Id}", id);
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Application shutdown while waiting for work.
}
}
}
We’ve handled each failure inside the loop such that unsuccessful scans don’t prevent subsequent jobs from happening. Note that in this compact version, we only perform one attempt. If we were to add retries, that would require bounded attempts, along with transient-error classification and respect for Retry-After.
Before builder.Build(), we register the shared components and configure the HTTP client:
builder.Services.AddSingleton<SubmissionStore>();
builder.Services.AddSingleton<ReviewQueue>();
builder.Services.AddSingleton<Detector>();
builder.Services.AddHostedService<ReviewWorker>();
builder.Services.AddHttpClient("Cloudmersive", client =>
{
client.BaseAddress = new Uri("https://api.cloudmersive.com/");
client.Timeout = TimeSpan.FromSeconds(60);
});
Requests are connected with the worker via the singleton store and the queue. The timeout bounds each remote call (its value is an application choice).
Returning Status and Enforcing Approval
The frontend can now poll a status route. This handler checks the authenticated owner before returning a limited view of the record. We’ll place both routes after builder.Build() and before app.Run(), with the using directive at the top of Program.cs:
using System.Security.Claims;
app.MapGet("/submissions/{id:guid}",
(Guid id, ClaimsPrincipal user, SubmissionStore store) =>
{
var ownerId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(ownerId) ||
!store.Items.TryGetValue(id, out var item) ||
item.OwnerId != ownerId)
{
return Results.NotFound();
}
return Results.Ok(new
{
item.Id,
Status = item.Status.ToString(),
item.UpdatedAt
});
}).RequireAuthorization();
The host needs to validate authentication and provide the same stable owner claim used during the upload process. The frontend will use the returned status to update its interface automatically.
That same ownership check is performed by the download route. This additional condition is then applied before the file is returned:
app.MapGet("/submissions/{id:guid}/file",
(Guid id, ClaimsPrincipal user, SubmissionStore store) =>
{
var ownerId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(ownerId) ||
!store.Items.TryGetValue(id, out var item) ||
item.OwnerId != ownerId)
{
return Results.NotFound();
}
if (item.Status != SubmissionStatus.Approved)
{
return Results.Conflict(new
{
Error = "Submission is not approved."
});
}
return Results.File(
item.FilePath,
"application/octet-stream",
fileDownloadName: "submission");
}).RequireAuthorization();
Note that private storage will prevent direct public access from bypassing this check, so human reviewers need a separate authorized access path.
Testing and Production Considerations
To verify things like approval, review, and missing-result handling, we can substitute controlled detector responses. In the event we get simulated request failures, they should produce Unverified, while downloads should remain blocked during processing. Critically, another user’s request should NOT reveal the submission.
It’s important to bear in mind that this demo code also loses its queue and records on restart. That’s obviously not production-ready, since production usually requires durable jobs and records on top of coordinated enqueuing and duplicate-job handling. Additionally, we should probably retain things like creation timestamps and attempt history in our production code.
Conclusion
In this article, we learned how to connect private portal uploads to a background AI-content detector through a shared record and queue. Our worker records an explicit outcome, our frontend retrieves it automatically, and our download route enforces content approval.
With this workflow in place, our application can acknowledge submissions promptly while keeping unfinished and unsuccessful content evaluations out of normal publication workflows.
Opinions expressed by DZone contributors are their own.
Comments