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:
Accept an image.
Send it to a vision model.
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
BackgroundServiceSeparate 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."
};
}
}
Join the conversation! Your thoughts help the community grow.