Modern web applications increasingly accept images as input.

A user may upload a screenshot and expect OCR text, send a product photo and request structured attributes, or provide an interface capture and ask questions about its contents.

At first, the backend may appear simple:

  1. Accept an image.

  2. Send it to a vision model.

  3. Return the generated result.

This design works for a prototype, but it becomes unreliable when real users upload large files, corrupted images, unsupported formats, or several requests at the same time.

A production system also needs to handle:

In this article, we will build a provider-independent image analysis API with ASP.NET Core.

The API will:

Products such as Describe Image demonstrate the practical value of turning images into descriptions, OCR text, alt text, prompts, product information, and reusable notes.

The implementation below is an independent architecture example and does not describe the internal implementation of any specific product.

1. Project Architecture

The API should return quickly after an image upload is accepted.

The actual vision-model request should run outside the HTTP request lifecycle.

The workflow will look like this:

Client
    ↓
POST /api/image-analysis
    ↓
Upload validation
    ↓
Private file storage
    ↓
Task creation
    ↓
Background queue
    ↓
ImageAnalysisWorker
    ↓
Vision provider
    ↓
Task result storage
    ↓
GET /api/image-analysis/{taskId}
    ↓
Client receives the status and result

This architecture prevents an HTTP connection from remaining open while an external model processes an image.

It also makes retries, monitoring, and task recovery easier to implement.

2. Create the ASP.NET Core Project

Create a new ASP.NET Core Web API project:

dotnet new webapi -n ImageAnalysisApi
cd ImageAnalysisApi

The sample uses standard ASP.NET Core services and does not require a specific AI provider.

3. Define the Analysis Modes

Different visual tasks require different instructions and output formats.

OCR should not use the same prompt as alt-text generation, product analysis, or a detailed image description.

Create a Models folder and add AnalysisMode.cs:

namespace ImageAnalysisApi.Models;

public enum AnalysisMode
{
    DetailedDescription,
    Ocr,
    AltText,
    ProductAnalysis
}

Using an enum prevents clients from submitting arbitrary mode names and gives the backend a stable list of supported operations.

4. Define the Task States

A Boolean property such as IsComplete is not enough for a long-running workflow.

The client needs to know whether a task is waiting, actively processing, completed, or failed.

Create AnalysisTaskStatus.cs:

namespace ImageAnalysisApi.Models;

public enum AnalysisTaskStatus
{
    Queued,
    Processing,
    Completed,
    Failed
}

Now create ImageAnalysisTask.cs:

namespace ImageAnalysisApi.Models;

public sealed class ImageAnalysisTask
{
    public required Guid Id { get; init; }

    public required string StoredFilePath { get; init; }

    public required string ContentType { get; init; }

    public required AnalysisMode Mode { get; init; }

    public AnalysisTaskStatus Status { get; set; }

    public string? Result { get; set; }

    public string? ErrorCode { get; set; }

    public string? ErrorMessage { get; set; }

    public DateTimeOffset CreatedAt { get; init; }

    public DateTimeOffset UpdatedAt { get; set; }
}

Detailed states improve:

A production application should store these records in a durable database.

5. Create the Task Store

The task store separates persistence logic from controllers and background workers.

Create IAnalysisTaskStore.cs:

using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public interface IAnalysisTaskStore
{
    Task CreateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken);

    Task<ImageAnalysisTask?> GetAsync(
        Guid taskId,
        CancellationToken cancellationToken);

    Task UpdateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken);
}

For this tutorial, use a thread-safe in-memory implementation.

Create InMemoryAnalysisTaskStore.cs:

using System.Collections.Concurrent;
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public sealed class InMemoryAnalysisTaskStore
    : IAnalysisTaskStore
{
    private readonly ConcurrentDictionary<Guid, ImageAnalysisTask>
        _tasks = new();

    public Task CreateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (!_tasks.TryAdd(task.Id, task))
        {
            throw new InvalidOperationException(
                $"Task {task.Id} already exists.");
        }

        return Task.CompletedTask;
    }

    public Task<ImageAnalysisTask?> GetAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        _tasks.TryGetValue(taskId, out var task);

        return Task.FromResult(task);
    }

    public Task UpdateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        _tasks[task.Id] = task;

        return Task.CompletedTask;
    }
}

Because this implementation stores everything in memory, all tasks disappear when the application restarts.

That is acceptable for a tutorial, but not for a production deployment.

A real implementation can use:

6. Validate Uploaded Files

Never trust the original file name, extension, or browser-provided content type by itself.

The server should verify:

Create IImageUploadValidator.cs:

namespace ImageAnalysisApi.Services;

public interface IImageUploadValidator
{
    Task ValidateAsync(
        IFormFile file,
        CancellationToken cancellationToken);
}

Create ImageUploadValidator.cs:

namespace ImageAnalysisApi.Services;

public sealed class ImageUploadValidator
    : IImageUploadValidator
{
    private const long MaxFileSize =
        10 * 1024 * 1024;

    private static readonly HashSet<string>
        AllowedContentTypes =
        new(StringComparer.OrdinalIgnoreCase)
    {
        "image/jpeg",
        "image/png",
        "image/webp"
    };

    public async Task ValidateAsync(
        IFormFile file,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(file);

        if (file.Length == 0)
        {
            throw new InvalidDataException(
                "The uploaded file is empty.");
        }

        if (file.Length > MaxFileSize)
        {
            throw new InvalidDataException(
                "The uploaded file exceeds the 10 MB limit.");
        }

        if (!AllowedContentTypes.Contains(file.ContentType))
        {
            throw new InvalidDataException(
                "Only JPEG, PNG, and WebP images are supported.");
        }

        await using var stream =
            file.OpenReadStream();

        var header = new byte[12];

        var bytesRead = await stream.ReadAsync(
            header.AsMemory(0, header.Length),
            cancellationToken);

        if (!MatchesSupportedSignature(
            header.AsSpan(0, bytesRead)))
        {
            throw new InvalidDataException(
                "The file signature does not match a supported image.");
        }
    }

    private static bool MatchesSupportedSignature(
        ReadOnlySpan<byte> header)
    {
        return IsJpeg(header)
            || IsPng(header)
            || IsWebP(header);
    }

    private static bool IsJpeg(
        ReadOnlySpan<byte> header)
    {
        return header.Length >= 3
            && header[0] == 0xFF
            && header[1] == 0xD8
            && header[2] == 0xFF;
    }

    private static bool IsPng(
        ReadOnlySpan<byte> header)
    {
        byte[] signature =
        {
            0x89, 0x50, 0x4E, 0x47,
            0x0D, 0x0A, 0x1A, 0x0A
        };

        return header.Length >= signature.Length
            && header[..signature.Length]
                .SequenceEqual(signature);
    }

    private static bool IsWebP(
        ReadOnlySpan<byte> header)
    {
        byte[] riff =
        {
            0x52, 0x49, 0x46, 0x46
        };

        byte[] webp =
        {
            0x57, 0x45, 0x42, 0x50
        };

        return header.Length >= 12
            && header[..4].SequenceEqual(riff)
            && header.Slice(8, 4).SequenceEqual(webp);
    }
}

File-signature validation prevents obvious extension and MIME-type spoofing, but it is not a complete security solution.

A production system should also consider:

7. Store Files with Safe Names

Do not use the original upload name as the physical storage name.

The original name may contain:

Create IImageStorage.cs:

namespace ImageAnalysisApi.Services;

public interface IImageStorage
{
    Task<string> SaveAsync(
        IFormFile file,
        CancellationToken cancellationToken);

    Task<Stream> OpenReadAsync(
        string storedPath,
        CancellationToken cancellationToken);
}

Create LocalImageStorage.cs:

namespace ImageAnalysisApi.Services;

public sealed class LocalImageStorage
    : IImageStorage
{
    private readonly string _uploadDirectory;

    public LocalImageStorage(
        IWebHostEnvironment environment)
    {
        _uploadDirectory = Path.Combine(
            environment.ContentRootPath,
            "App_Data",
            "uploads");

        Directory.CreateDirectory(
            _uploadDirectory);
    }

    public async Task<string> SaveAsync(
        IFormFile file,
        CancellationToken cancellationToken)
    {
        var extension =
            GetSafeExtension(file.ContentType);

        var generatedName =
            $"{Guid.NewGuid():N}{extension}";

        var fullPath = Path.Combine(
            _uploadDirectory,
            generatedName);

        await using var destination =
            new FileStream(
                fullPath,
                FileMode.CreateNew,
                FileAccess.Write,
                FileShare.None,
                bufferSize: 81920,
                useAsync: true);

        await file.CopyToAsync(
            destination,
            cancellationToken);

        return fullPath;
    }

    public Task<Stream> OpenReadAsync(
        string storedPath,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        Stream stream = new FileStream(
            storedPath,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read,
            bufferSize: 81920,
            useAsync: true);

        return Task.FromResult(stream);
    }

    private static string GetSafeExtension(
        string contentType)
    {
        return contentType.ToLowerInvariant() switch
        {
            "image/jpeg" => ".jpg",
            "image/png" => ".png",
            "image/webp" => ".webp",
            _ => throw new InvalidDataException(
                "Unsupported image type.")
        };
    }
}

The upload directory is placed under App_Data instead of wwwroot.

This prevents uploaded files from automatically becoming public web assets.

For distributed deployments, private object storage is usually more appropriate than local disk.

Possible services include:

8. Create an Asynchronous Task Queue

Channel<T> can be used to create a bounded in-process queue.

A bounded queue provides backpressure and prevents unlimited memory growth.

Create IAnalysisTaskQueue.cs:

namespace ImageAnalysisApi.Services;

public interface IAnalysisTaskQueue
{
    ValueTask QueueAsync(
        Guid taskId,
        CancellationToken cancellationToken);

    ValueTask<Guid> DequeueAsync(
        CancellationToken cancellationToken);
}

Create AnalysisTaskQueue.cs:

using System.Threading.Channels;

namespace ImageAnalysisApi.Services;

public sealed class AnalysisTaskQueue
    : IAnalysisTaskQueue
{
    private readonly Channel<Guid> _channel;

    public AnalysisTaskQueue()
    {
        var options =
            new BoundedChannelOptions(100)
            {
                FullMode =
                    BoundedChannelFullMode.Wait,
                SingleReader = true,
                SingleWriter = false
            };

        _channel =
            Channel.CreateBounded<Guid>(options);
    }

    public ValueTask QueueAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        return _channel.Writer.WriteAsync(
            taskId,
            cancellationToken);
    }

    public ValueTask<Guid> DequeueAsync(
        CancellationToken cancellationToken)
    {
        return _channel.Reader.ReadAsync(
            cancellationToken);
    }
}

An in-process queue is appropriate for:

For multiple application instances, use a durable message broker such as:

An in-process queue loses pending items when the application restarts.

9. Abstract the Vision Provider

Vendor-specific model calls should not be placed directly inside the controller.

Create an interface so the provider can be changed without rewriting the rest of the application.

Create IVisionAnalysisProvider.cs:

using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public interface IVisionAnalysisProvider
{
    Task<string> AnalyzeAsync(
        Stream image,
        string contentType,
        AnalysisMode mode,
        CancellationToken cancellationToken);
}

For this article, create a simulated provider.

Create DemoVisionAnalysisProvider.cs:

using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public sealed class DemoVisionAnalysisProvider
    : IVisionAnalysisProvider
{
    public async Task<string> AnalyzeAsync(
        Stream image,
        string contentType,
        AnalysisMode mode,
        CancellationToken cancellationToken)
    {
        await Task.Delay(
            TimeSpan.FromSeconds(2),
            cancellationToken);

        return mode switch
        {
            AnalysisMode.Ocr =>
                "Demo OCR result: visible text would appear here.",

            AnalysisMode.AltText =>
                "A concise alt-text description of the uploaded image.",

            AnalysisMode.ProductAnalysis =>
                "A structured description of visible product attributes.",

            _ =>
                "A detailed description of the uploaded image."
        };
    }
}

A real implementation may call:

The controller and worker do not need to know which provider is used.

They only depend on IVisionAnalysisProvider.

10. Process Tasks with BackgroundService

The background worker will:

  1. Dequeue a task ID

  2. Load the task record

  3. Confirm that it is still queued

  4. Mark it as processing

  5. Open the stored image

  6. Call the vision provider

  7. Store the result

  8. Mark the task as completed or failed

Create ImageAnalysisWorker.cs:

using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public sealed class ImageAnalysisWorker
    : BackgroundService
{
    private readonly IAnalysisTaskQueue _queue;
    private readonly IAnalysisTaskStore _taskStore;
    private readonly IImageStorage _storage;
    private readonly IVisionAnalysisProvider _provider;
    private readonly ILogger<ImageAnalysisWorker> _logger;

    public ImageAnalysisWorker(
        IAnalysisTaskQueue queue,
        IAnalysisTaskStore taskStore,
        IImageStorage storage,
        IVisionAnalysisProvider provider,
        ILogger<ImageAnalysisWorker> logger)
    {
        _queue = queue;
        _taskStore = taskStore;
        _storage = storage;
        _provider = provider;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var taskId =
                await _queue.DequeueAsync(
                    stoppingToken);

            try
            {
                await ProcessTaskAsync(
                    taskId,
                    stoppingToken);
            }
            catch (OperationCanceledException)
                when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception exception)
            {
                _logger.LogError(
                    exception,
                    "Unhandled error for task {TaskId}.",
                    taskId);
            }
        }
    }

    private async Task ProcessTaskAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        var task = await _taskStore.GetAsync(
            taskId,
            cancellationToken);

        if (task is null)
        {
            _logger.LogWarning(
                "Task {TaskId} was not found.",
                taskId);

            return;
        }

        if (task.Status != AnalysisTaskStatus.Queued)
        {
            _logger.LogInformation(
                "Task {TaskId} has status {Status} and will not be processed again.",
                task.Id,
                task.Status);

            return;
        }

        task.Status =
            AnalysisTaskStatus.Processing;

        task.UpdatedAt =
            DateTimeOffset.UtcNow;

        await _taskStore.UpdateAsync(
            task,
            cancellationToken);

        try
        {
            await using var imageStream =
                await _storage.OpenReadAsync(
                    task.StoredFilePath,
                    cancellationToken);

            var result =
                await _provider.AnalyzeAsync(
                    imageStream,
                    task.ContentType,
                    task.Mode,
                    cancellationToken);

            if (string.IsNullOrWhiteSpace(result))
            {
                throw new InvalidOperationException(
                    "The vision provider returned an empty result.");
            }

            task.Result = result;

            task.Status =
                AnalysisTaskStatus.Completed;

            task.ErrorCode = null;
            task.ErrorMessage = null;
        }
        catch (OperationCanceledException)
            when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception exception)
        {
            task.Status =
                AnalysisTaskStatus.Failed;

            task.ErrorCode =
                "ANALYSIS_FAILED";

            task.ErrorMessage =
                "The image could not be analyzed.";

            _logger.LogError(
                exception,
                "Image analysis failed for task {TaskId}.",
                task.Id);
        }
        finally
        {
            task.UpdatedAt =
                DateTimeOffset.UtcNow;

            await _taskStore.UpdateAsync(
                task,
                CancellationToken.None);
        }
    }
}

The worker checks that the task is still queued before processing it.

This reduces accidental duplicate work.

A distributed production system should claim tasks through:

Checking the task status in memory is not enough for strict distributed idempotency.

11. Create the API Controller

The POST endpoint will:

The GET endpoint will return the current status and result.

Create ImageAnalysisController.cs:

using ImageAnalysisApi.Models;
using ImageAnalysisApi.Services;
using Microsoft.AspNetCore.Mvc;

namespace ImageAnalysisApi.Controllers;

[ApiController]
[Route("api/image-analysis")]
public sealed class ImageAnalysisController
    : ControllerBase
{
    private readonly IImageUploadValidator _validator;
    private readonly IImageStorage _storage;
    private readonly IAnalysisTaskStore _taskStore;
    private readonly IAnalysisTaskQueue _taskQueue;

    public ImageAnalysisController(
        IImageUploadValidator validator,
        IImageStorage storage,
        IAnalysisTaskStore taskStore,
        IAnalysisTaskQueue taskQueue)
    {
        _validator = validator;
        _storage = storage;
        _taskStore = taskStore;
        _taskQueue = taskQueue;
    }

    [HttpPost]
    [Consumes("multipart/form-data")]
    [ProducesResponseType(
        StatusCodes.Status202Accepted)]
    [ProducesResponseType(
        StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> CreateAsync(
        [FromForm] IFormFile image,
        [FromForm] AnalysisMode mode,
        CancellationToken cancellationToken)
    {
        try
        {
            await _validator.ValidateAsync(
                image,
                cancellationToken);
        }
        catch (InvalidDataException exception)
        {
            return BadRequest(new
            {
                error = exception.Message
            });
        }

        var storedPath =
            await _storage.SaveAsync(
                image,
                cancellationToken);

        var now =
            DateTimeOffset.UtcNow;

        var task =
            new ImageAnalysisTask
            {
                Id = Guid.NewGuid(),
                StoredFilePath = storedPath,
                ContentType = image.ContentType,
                Mode = mode,
                Status =
                    AnalysisTaskStatus.Queued,
                CreatedAt = now,
                UpdatedAt = now
            };

        await _taskStore.CreateAsync(
            task,
            cancellationToken);

        await _taskQueue.QueueAsync(
            task.Id,
            cancellationToken);

        return AcceptedAtAction(
            nameof(GetAsync),
            new
            {
                taskId = task.Id
            },
            new
            {
                taskId = task.Id,
                status =
                    task.Status.ToString(),
                statusUrl =
                    Url.ActionLink(
                        nameof(GetAsync),
                        values: new
                        {
                            taskId = task.Id
                        })
            });
    }

    [HttpGet("{taskId:guid}")]
    [ProducesResponseType(
        StatusCodes.Status200OK)]
    [ProducesResponseType(
        StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        var task =
            await _taskStore.GetAsync(
                taskId,
                cancellationToken);

        if (task is null)
        {
            return NotFound(new
            {
                error = "Task not found."
            });
        }

        return Ok(new
        {
            taskId = task.Id,
            mode =
                task.Mode.ToString(),
            status =
                task.Status.ToString(),
            result =
                task.Status ==
                AnalysisTaskStatus.Completed
                    ? task.Result
                    : null,
            errorCode =
                task.ErrorCode,
            errorMessage =
                task.Status ==
                AnalysisTaskStatus.Failed
                    ? task.ErrorMessage
                    : null,
            createdAt =
                task.CreatedAt,
            updatedAt =
                task.UpdatedAt
        });
    }
}

Returning HTTP 202 Accepted tells the client that the request has been accepted, but processing is not complete.

The client can poll the status endpoint until the task becomes Completed or Failed.

12. Register the Services

Update Program.cs:

using ImageAnalysisApi.Services;

var builder =
    WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddSingleton<
    IAnalysisTaskStore,
    InMemoryAnalysisTaskStore>();

builder.Services.AddSingleton<
    IAnalysisTaskQueue,
    AnalysisTaskQueue>();

builder.Services.AddSingleton<
    IImageUploadValidator,
    ImageUploadValidator>();

builder.Services.AddSingleton<
    IImageStorage,
    LocalImageStorage>();

builder.Services.AddSingleton<
    IVisionAnalysisProvider,
    DemoVisionAnalysisProvider>();

builder.Services.AddHostedService<
    ImageAnalysisWorker>();

var app =
    builder.Build();

app.UseHttpsRedirection();

app.MapControllers();

app.Run();

The task store, queue, validator, storage service, and demo provider are singletons in this simplified example.

If a future implementation uses Entity Framework Core, resolve scoped services inside a dependency-injection scope created by the worker.

13. Test the API

Start the application:

dotnet run

Submit an image with cURL:

curl -X POST "https://localhost:7001/api/image-analysis" \
-F "[email protected];type=image/png" \
-F "mode=DetailedDescription"

A successful response resembles:

{
  "taskId": "4af8f71b-f93f-44c2-9358-472e8f52497d",
  "status": "Queued",
  "statusUrl": "https://localhost:7001/api/image-analysis/4af8f71b-f93f-44c2-9358-472e8f52497d"
}

Poll the status endpoint:

curl "https://localhost:7001/api/image-analysis/4af8f71b-f93f-44c2-9358-472e8f52497d"

While processing:

{
  "taskId": "4af8f71b-f93f-44c2-9358-472e8f52497d",
  "mode": "DetailedDescription",
  "status": "Processing",
  "result": null,
  "errorCode": null,
  "errorMessage": null,
  "createdAt": "2026-08-03T07:00:00+00:00",
  "updatedAt": "2026-08-03T07:00:01+00:00"
}

After completion:

{
  "taskId": "4af8f71b-f93f-44c2-9358-472e8f52497d",
  "mode": "DetailedDescription",
  "status": "Completed",
  "result": "A detailed description of the uploaded image.",
  "errorCode": null,
  "errorMessage": null,
  "createdAt": "2026-08-03T07:00:00+00:00",
  "updatedAt": "2026-08-03T07:00:03+00:00"
}

14. Return Structured Results

A plain string is enough for a basic prototype, but structured output is easier to validate and consume.

For example:

namespace ImageAnalysisApi.Models;

public sealed record ImageAnalysisResult(
    string Summary,
    IReadOnlyList<string> VisibleText,
    IReadOnlyList<DetectedObject> Objects,
    IReadOnlyList<string> Warnings);

public sealed record DetectedObject(
    string Name,
    double? Confidence);

The provider interface can then return ImageAnalysisResult instead of string:

using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public interface IStructuredVisionAnalysisProvider
{
    Task<ImageAnalysisResult> AnalyzeAsync(
        Stream image,
        string contentType,
        AnalysisMode mode,
        CancellationToken cancellationToken);
}

Structured output is useful because it allows the frontend to display:

Do not assume that an external model will always return valid JSON.

Validate provider output before saving it or returning it to the client.

15. Separate Visible Facts from Inference

Image-analysis models may generate plausible assumptions that are not directly supported by the image.

For product analysis, the output should distinguish between:

A structured model might look like this:

public sealed record ProductImageAnalysis(
    IReadOnlyDictionary<string, string> VisibleAttributes,
    IReadOnlyDictionary<string, string> PossibleAttributes,
    IReadOnlyList<string> UnknownAttributes);

For example:

{
  "visibleAttributes": {
    "color": "black",
    "closure": "zipper",
    "surface": "matte"
  },
  "possibleAttributes": {
    "material": "possibly synthetic fabric"
  },
  "unknownAttributes": [
    "exact dimensions",
    "weight",
    "manufacturer",
    "water resistance"
  ]
}

This prevents the application from presenting guesses as verified specifications.

16. Add Explicit Provider Timeouts

An unavailable external provider should not block the worker indefinitely.

A provider implementation can use HttpClient with an explicit timeout:

builder.Services.AddHttpClient(
    "VisionProvider",
    client =>
    {
        client.Timeout =
            TimeSpan.FromSeconds(60);
    });

The provider should also respect the CancellationToken passed by the worker.

Timeouts should be classified separately from permanent failures so that transient errors can be retried safely.

17. Retry Only Transient Errors

Not every failure should be retried.

Possible failures include:

A retry policy might behave like this:

Repeating the complete pipeline can create duplicate provider charges.

Each expensive stage should be independently recoverable where possible.

18. Delete Temporary Files

The sample keeps uploaded files on disk.

A production application needs a retention policy.

Possible approaches include:

Do not rely only on a privacy-policy statement.

Retention rules should be enforced by code.

19. Add Authentication and Rate Limiting

Image analysis can be expensive.

Without limits, an anonymous user may upload many large files and create excessive provider costs.

A production API should consider:

ASP.NET Core rate limiting can protect the upload endpoint before a task reaches the background queue.

20. Improve Observability

Do not log raw images, full OCR results, or private storage URLs by default.

Instead, log safe operational information:

This provides enough information for monitoring without turning application logs into a database of sensitive image contents.

21. Buffered Uploads Versus Streaming

IFormFile uses buffered upload handling and is convenient for smaller files.

For very large images, high concurrency, or video uploads, consider multipart streaming.

Streaming prevents the application from buffering excessive content in memory or temporary disk space.

The correct choice depends on:

22. Why This Architecture Is Maintainable

The design separates responsibilities:

This separation makes the system easier to test and modify.

You can replace the demo provider without changing the API contract.

You can move from local disk to object storage without rewriting the worker.

You can replace the in-memory task store with a database without changing the controller.

You can introduce a durable queue without changing the vision provider.

Conclusion

Building a reliable image analysis API requires more than sending an image to an AI model.

A production-oriented system must validate uploads, generate safe file names, control resource usage, store files securely, process long-running tasks asynchronously, expose clear task states, validate model output, and recover from failures without duplicating expensive work.

ASP.NET Core provides the core components required for this architecture, including dependency injection, controllers, hosted background services, asynchronous file operations, and structured configuration.

The provider-independent design can be extended for:

The most important principle is to treat the vision model as one component inside a larger application boundary.

The model generates an answer, but the surrounding application determines whether that answer is secure, traceable, recoverable, and useful.

Summary

This article demonstrated how to build a provider-independent image analysis API with ASP.NET Core using asynchronous processing, secure file validation, private storage, background workers, task queues, and a pluggable vision provider architecture. By separating responsibilities and implementing production-oriented patterns such as structured task states, retry strategies, observability, and rate limiting, the application becomes more maintainable, scalable, and resilient for real-world deployments.