How to Download Images in C# with HttpClient

Replaced repeated snippets and unsupported timing comparisons with a complete HttpClient program and owned PNG/JPEG failure tests. Corrected legacy API guidance and added body-size limits, body cancellation and temporary-file cleanup. Runnable evidence.
To download an image from a URL in C#, use HttpClient to request the bytes and save them to a file. For a bounded download, inspect the status and content type, stream the body with a byte limit and cancellation token, and publish the destination only after the transfer passes your checks.
The program below accepts PNG and JPEG responses. It checks their declared media type and leading signature bytes, preserves an existing destination and removes temporary bytes when a transfer fails. Those checks are useful format filters; they do not fully decode an image or prove that arbitrary content is safe.
Run the tested example
Install the .NET SDK, Git and Bash. The local test harness additionally needs Python 3. The dated run used .NET SDK 10.0.401, runtime 10.0.12 and Python 3.10.2. The .NET program has no third-party package dependencies.
git clone https://github.com/ScrapingAnt/scrapingant-examples.git
cd scrapingant-examples
git checkout 155903f93eabd1fc3a3c50c9694a85e69ea4d0fe
cd examples/download-image-c-sharp
./run.sh
The harness starts an owned loopback HTTP server, tests successful and rejected downloads and compares results with the saved capture. It needs no API key and does not call an external image host.
To download from an application-selected HTTPS URL, use the same program with a URL you control. This is an invocation template; replace the placeholder before running it:
dotnet run -- https://your-controlled-host.example/image.png image.png --max-bytes 5242880 --timeout-ms 15000
The default cap is 5 MiB and the default deadline is 15 seconds. These are example policy choices, not recommended limits for every application. The parent destination directory must already exist. An existing destination causes failure instead of overwrite.
The complete C# downloader
Save these two source files in the same project. The first is the tested Program.cs entry point:
try
{
if (args.Length < 2)
throw new ArgumentException("Usage: dotnet run -- <https-url> <destination> [--max-bytes N] [--timeout-ms N]");
var url = new Uri(args[0]);
// Explicit opt-in for this repository's local fixture only.
var localFixture = args.Contains("--allow-loopback") && url.IsLoopback;
if (url.Scheme != "https" && !(localFixture && url.Scheme == "http"))
throw new ArgumentException("Use an HTTPS URL; HTTP is only allowed for opted-in loopback fixtures.");
int Option(string name, int fallback)
{
var i = Array.IndexOf(args, name);
if (i < 0) return fallback;
if (i + 1 == args.Length) throw new ArgumentException(name + " needs a value.");
return int.Parse(args[i + 1], System.Globalization.CultureInfo.InvariantCulture);
}
var maxBytes = Option("--max-bytes", 5 * 1024 * 1024);
var timeoutMs = Option("--timeout-ms", 15000);
if (maxBytes <= 0 || timeoutMs <= 0) throw new ArgumentException("Limits must be positive.");
using var client = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false });
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs));
var bytes = await ImageDownloader.SaveAsync(client, url, args[1], maxBytes, timeout.Token);
Console.WriteLine($"saved {bytes} bytes");
}
catch (Exception error) when (error is HttpRequestException or IOException or InvalidDataException or
OperationCanceledException or ArgumentException or FormatException or OverflowException)
{
Console.Error.WriteLine(error.GetType().Name + ": " + error.Message);
Environment.ExitCode = 1;
}
ImageDownloader.cs contains the transfer and file-publishing logic:
public static class ImageDownloader
{
public static async Task<long> SaveAsync(HttpClient client, Uri url, string destination,
long maxBytes, CancellationToken cancellationToken)
{
if (maxBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxBytes));
using var response = await client.GetAsync(url,
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
if (response.StatusCode != System.Net.HttpStatusCode.OK)
throw new InvalidDataException("Expected a complete HTTP 200 response.");
var type = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
if (type is not ("image/png" or "image/jpeg"))
throw new InvalidDataException("Expected image/png or image/jpeg.");
if (response.Content.Headers.ContentLength is long size && size > maxBytes)
throw new InvalidDataException("Declared body exceeds the byte limit.");
destination = Path.GetFullPath(destination);
var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".part";
try
{
long total = 0;
var prefix = new byte[8];
var prefixLength = 0;
await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken))
await using (var output = new FileStream(temporary, FileMode.CreateNew,
FileAccess.Write, FileShare.None, 16384, FileOptions.Asynchronous))
{
var buffer = new byte[16384];
int read;
while ((read = await input.ReadAsync(buffer, cancellationToken)) != 0)
{
if (read > maxBytes - total)
throw new InvalidDataException("Received body exceeds the byte limit.");
var take = Math.Min(read, prefix.Length - prefixLength);
buffer.AsSpan(0, take).CopyTo(prefix.AsSpan(prefixLength));
prefixLength += take;
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
total += read;
}
}
byte[] signature = type == "image/png"
? [137, 80, 78, 71, 13, 10, 26, 10] : [255, 216, 255];
if (prefixLength < signature.Length ||
!prefix.AsSpan(0, signature.Length).SequenceEqual(signature))
throw new InvalidDataException("Body signature does not match the declared image type.");
cancellationToken.ThrowIfCancellationRequested();
// Same-directory move; fail instead of overwriting an existing destination.
File.Move(temporary, destination, overwrite: false);
return total;
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
}
The command normally requires HTTPS. The explicit --allow-loopback switch exists only for reproducing the packet's local HTTP fixtures. It does not disable certificate validation. Redirects are disabled: inspect and choose a final approved URL if a server sends a redirect. The downloader requires a complete HTTP 200 response, rather than treating a partial response as the whole image.
The method accepts an existing HttpClient, so a longer-lived application can reuse its client across downloads. Follow Microsoft's HttpClient lifetime guidance for connection pooling and DNS changes; the short command-line process creates one client for its one transfer.
Why use ResponseHeadersRead and a body token?
ResponseHeadersRead lets the code inspect headers before consuming the complete body. Microsoft's HttpCompletionOption documentation makes a key distinction: with this option, HttpClient.Timeout applies only until the headers are received. Subsequent body reads need their own cancellation or timeout policy.
This program passes the same deadline token to the request, stream acquisition, reads and writes. It rejects a declared oversized body early, then counts received bytes as well. The second check covers a response without Content-Length; the header alone is not the size limit.
The temporary file is created next to the destination. After the complete read, the code checks the PNG or JPEG signature and moves that file without overwrite. A transfer exception, size violation, cancellation or failed move enters the cleanup path. This does not provide a filesystem crash-durability guarantee.
What the local tests actually showed
The packet's synthetic PNG is 68 bytes; its JPEG conversion is 4670 bytes. The successful downloads matched each fixture byte for byte, with recorded SHA256 hashes. All 15 local cases passed:
| Owned fixture case | Observed result |
|---|---|
ok | Saved exact fixture bytes |
jpeg | Saved exact fixture bytes |
http404 | Rejected; no destination published |
html | Rejected; no destination published |
forged_png_mime | Rejected; no destination published |
missing_mime | Rejected; no destination published |
mismatched_signature | Rejected; no destination published |
empty | Rejected; no destination published |
declared_oversize | Rejected; no destination published |
unknown_length_oversize | Rejected; no destination published |
truncated | Rejected; no destination published |
body_timeout | Rejected; no destination published |
redirect | Rejected; no destination published |
existing_file | Rejected; existing destination preserved |
http_without_opt_in | Rejected; no destination published |
The body-timeout case sends headers and a body prefix before delaying the rest. Its cancellation assertion therefore exercises the body stage. The unknown-length oversized response tests the received-byte counter. Every rejection returns controlled exit code 1 and a diagnostic on standard error. Negative cases assert that no new destination or .part file remains; the existing-file case additionally checks that its old bytes are unchanged.
These are local correctness tests. They do not measure Internet download speed, public-host availability, TLS failures, proxy behavior or provider success rates. The original article's pasted millisecond comparisons were removed because they did not establish how this program performs on a reader's workload.
Content checks, errors and retries
| Failure | Next action |
|---|---|
| HTTP error or redirect | Inspect the status and final URL; do not save an error page as an image |
| Missing/wrong media type or signature | Inspect the response; it may be HTML, a login page or a different format |
| Size limit exceeded | Decide whether to raise a deliberate cap or reject the resource |
| Cancellation or truncated response | Keep the destination unpublished; decide whether a fresh attempt is appropriate |
| Existing file or unavailable directory | Choose a valid destination and an explicit overwrite policy |
image/png or image/jpeg is a server claim. Matching the leading bytes narrows the format, but a corrupt file can still have that signature. If the next step decodes or transforms untrusted images, use an appropriate decoder with resource limits and review its licensing and supported formats separately. This downloader does not validate PNG CRCs, image dimensions, full JPEG structure or malware.
The example performs one attempt. Add retries only for failures you classify as transient, with a bounded policy; do not blindly retry invalid content, a permanent HTTP error or a local file-policy failure.
Use trusted, application-selected URLs and destinations. This code is not an arbitrary user-URL fetch service: HTTPS and signature checks do not prevent requests to unwanted network addresses. Server-side applications accepting user URLs need a separate network-access policy.
HttpClient, WebClient and image processing
For new .NET code, start with HttpClient. Microsoft marks WebRequest, WebClient and ServicePoint obsolete beginning with .NET 6 and recommends HttpClient. Existing legacy applications may need a migration plan, but those older APIs are not the new tutorial's starting point.
Downloading bytes and resizing/decoding an image are separate tasks. An image-processing library is not required to save a direct accessible image URL. ScrapingAnt is also not needed for that direct download. If the URL itself is generated by a page's JavaScript, first solve the HTML-retrieval/extraction step; consult the ScrapingAnt request/response documentation for that separate workflow. No ScrapingAnt API request was run here.
For other languages, see Python image downloads, Node.js image downloads and Go image downloads. For extracting URLs from a document first, see HTML parsing in .NET.
Examples tested on 2026-10-08 with .NET SDK 10.0.401 and runtime 10.0.12. Code, owned fixtures and captured outputs.
This article was prepared with AI assistance from executed examples and independently reviewed. Oleg Kulyk is responsible for corrections.