ASP.NET Core  

Building a Production-Ready Image Analysis API with ASP.NET Core

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:

  • Secure file validation

  • Private storage

  • Long-running tasks

  • External provider timeouts

  • Duplicate processing

  • Task recovery

  • Structured error states

  • Frontend status polling

  • Usage limits

  • Privacy and data retention

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

The API will:

  • Validate uploaded images on the server

  • Generate safe internal file names

  • Store images outside the public web directory

  • Create asynchronous processing tasks

  • Queue tasks with Channel<T>

  • Run analysis with BackgroundService

  • Separate application code from the vision provider

  • Expose a task-status endpoint

  • Return completed and failed states clearly

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:

  • Frontend progress handling

  • Retry logic

  • Operational monitoring

  • Customer support

  • Failure diagnosis

  • Usage accounting

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:

  • SQL Server

  • PostgreSQL

  • MySQL

  • Redis

  • Azure Cosmos DB

  • Another durable task store

6. Validate Uploaded Files

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

The server should verify:

  • A file was supplied

  • The file is not empty

  • The file does not exceed the size limit

  • The declared content type is supported

  • The file signature matches the expected image format

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:

  • Decoding the image with a trusted image library

  • Rejecting excessive image dimensions

  • Limiting the total pixel count

  • Removing unnecessary metadata

  • Running malware scanning

  • Applying per-user rate limits

  • Restricting concurrent tasks

7. Store Files with Safe Names

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

The original name may contain:

  • Unsafe characters

  • Misleading extensions

  • Path-related values

  • Personally identifiable information

  • Duplicate names

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:

  • Azure Blob Storage

  • Amazon S3

  • Google Cloud Storage

  • Cloudflare R2

  • MinIO

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:

  • Tutorials

  • Small projects

  • Single-instance applications

  • Local development

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

  • Azure Service Bus

  • RabbitMQ

  • Amazon SQS

  • Apache Kafka

  • Google Cloud Pub/Sub

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:

  • A cloud computer-vision API

  • A multimodal language model

  • A self-hosted vision model

  • An internal Python inference service

  • A separate microservice

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:

  • An atomic database update

  • A row lock

  • A distributed lock

  • A unique stage-execution record

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

11. Create the API Controller

The POST endpoint will:

  • Validate the image

  • Store the file

  • Create a task

  • Add the task to the queue

  • Return HTTP 202 Accepted

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:

  • A short summary

  • Extracted OCR text

  • Detected objects

  • Confidence values

  • Warnings

  • Unknown or unverified information

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:

  • Visible attributes

  • Possible interpretations

  • Unknown information

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:

  • Invalid input

  • Unsupported image format

  • Provider timeout

  • Rate limiting

  • Invalid provider output

  • Moderation rejection

  • Storage failure

  • Network interruption

A retry policy might behave like this:

  • Invalid input: do not retry

  • Unsupported format: do not retry

  • Timeout: retry with exponential backoff

  • Rate limit: retry after the provider delay

  • Invalid output: retry once with a repair request

  • Storage failure: retry storage without repeating analysis

  • Moderation rejection: do not retry automatically

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:

  • Delete the original after successful analysis

  • Keep it for a limited number of hours

  • Keep it only for authenticated users

  • Allow users to delete it manually

  • Store results longer than source images

  • Run scheduled cleanup jobs

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:

  • User authentication

  • API keys

  • Per-user daily limits

  • Per-IP limits

  • Maximum concurrent tasks

  • Maximum storage usage

  • Mode-specific credit costs

  • Request deduplication

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:

  • Task ID

  • User ID when appropriate

  • Analysis mode

  • File size

  • Image dimensions

  • Processing duration

  • Provider latency

  • Retry count

  • Failure code

  • Queue waiting time

  • Final task status

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:

  • Expected file size

  • Number of simultaneous uploads

  • Available memory

  • Temporary storage performance

  • Deployment architecture

  • Whether uploads are sent directly to object storage

22. Why This Architecture Is Maintainable

The design separates responsibilities:

  • The controller handles HTTP behavior

  • The validator handles upload rules

  • The storage service handles files

  • The queue controls asynchronous work

  • The worker coordinates processing

  • The provider handles model integration

  • The task store tracks state

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:

  • OCR

  • Alt text

  • Screenshot understanding

  • Product image analysis

  • Document-image extraction

  • Prompt generation

  • Visual question answering

  • Chart interpretation

  • Interface analysis

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.