HttpClient is the .NET type for talking to HTTP services: REST APIs, web pages, image hosts, anything that speaks HTTP. It handles connection pooling, request and response headers, response streaming, and timeouts behind a small, mostly-async surface. This lesson covers how to create an HttpClient, how to send GET, POST, PUT, and DELETE requests, how to read responses as strings, streams, or JSON, how to set headers, how to handle timeouts and cancellation, and how to deal with errors and status codes. It also flags the well-known instance-lifetime problem that motivates HttpClientFactory.
HttpClient lives in System.Net.Http. The simplest way to construct one is the parameterless constructor.
That one call did a lot of work. The client opened a TCP connection to api.example-store.com, performed the TLS handshake for HTTPS, sent the GET request, read the response body, and returned the result as a string. Most of that machinery is reused for the next request through the same client, which is why instance reuse matters.
A more common pattern in real code is to set a BaseAddress once, then make every request with a relative URL.
The trailing slash on the base address matters. new Uri("https://api.example-store.com/") plus the relative "products" produces https://api.example-store.com/products. Without the trailing slash, the URI combiner treats api.example-store.com as a "file" and replaces it, which is one of those quirks to watch for.
A larger issue hides in this example: how many HttpClient instances to create. The intuitive answer is "one per request, dispose it after." The intuitive answer is wrong.
Each new HttpClient() allocates a fresh HttpMessageHandler underneath, which owns its own TCP connections. Disposing the client puts the underlying socket into the TIME_WAIT state for a couple of minutes before the operating system can reuse the port. A tight loop exhausts the available outbound ports on the machine, a failure mode called socket exhaustion. The process starts seeing SocketException: An attempt was made to access a socket in a way forbidden by its access permissions, even when the code looks correct.
Creating and disposing an HttpClient per request looks tidy but leaks sockets through TIME_WAIT. The correct pattern is to share a single long-lived HttpClient, or use IHttpClientFactory (lesson 03), which solves both this and the DNS-staleness problem the long-lived approach has.
The short version: treat HttpClient like a long-lived connection pool, not like a StreamReader. One instance per service, kept around for the life of the app, used from many threads. HttpClient is thread-safe for its public read methods, so concurrent requests through the same instance are fine.
The simplest GET is GetStringAsync, which the previous section used. It returns a string with the response body, and throws if the HTTP status indicates an error. Three more methods come up just as often, each returning a different shape of response.
| Method | Return type | When to use |
|---|---|---|
GetStringAsync(url) | Task<string> | The whole body as text, with no need for status or headers. |
GetByteArrayAsync(url) | Task<byte[]> | Raw bytes (images, files), with no need for status or headers. |
GetStreamAsync(url) | Task<Stream> | Read the body incrementally, especially for big responses. |
GetAsync(url) | Task<HttpResponseMessage> | Access to the status code, headers, or handling non-2xx responses directly. |
The first three are convenience wrappers. They internally call GetAsync, check the status, and pull the body in their preferred shape. For more control, drop down to GetAsync directly.
HttpResponseMessage is the full response object. It holds the status code, the response headers, and a Content property that owns the body. The body isn't read off the wire until requested (via ReadAsStringAsync, ReadAsStreamAsync, or one of the JSON helpers), which allows deciding based on the headers whether to read the body at all.
The same HttpResponseMessage also implements IDisposable. Disposing it releases the underlying connection back to the pool. In modern C#, the standard pattern is using:
EnsureSuccessStatusCode throws an HttpRequestException if the status code is not in the 2xx range, a shorter way to say "if anything went wrong, throw here." Error handling is covered later in the lesson.
GET is half the story. Real applications also create records (POST), update them (PUT), and remove them (DELETE). All three are sent through HttpClient with the matching method, and all three look similar in code.
POST sends data to the server. The body is wrapped in an HttpContent instance. The two most common content types are StringContent (for sending an arbitrary string with a chosen media type) and FormUrlEncodedContent (for classic form posts).
StringContent takes the body, the encoding, and the media type. UTF-8 with application/json is the standard combination for REST APIs. The server typically responds with 201 Created and the new resource, which is what we read out at the end.
PutAsync has the same shape as PostAsync. By convention, PUT replaces a resource in its entirety, and the URL points at the specific resource being updated.
Some APIs return the updated resource, some return 204 No Content with an empty body. The status code indicates which.
DeleteAsync doesn't take a body, since DELETE requests typically don't have one. The URL identifies what to remove.
204 No Content is the typical "succeeded, no body" response for DELETE. This example skips EnsureSuccessStatusCode on purpose; the code branches on IsSuccessStatusCode instead, which fits when failure should be handled without an exception.
GetAsync, PostAsync, PutAsync, and DeleteAsync are convenience wrappers over SendAsync. For full control over the request (custom method, custom headers per request, custom content), build an HttpRequestMessage and call SendAsync directly.
HttpRequestMessage allows setting headers per request, sending methods that don't have convenience wrappers (PATCH, HEAD, OPTIONS), and reusing the same client for any HTTP verb. It also comes up in the headers section.
Every response body comes from HttpResponseMessage.Content, which exposes three main read methods.
| Method | Returns | Memory | Good for |
|---|---|---|---|
ReadAsStringAsync | Task<string> | Whole body in one string | Small JSON, HTML pages, anything that fits comfortably in memory |
ReadAsByteArrayAsync | Task<byte[]> | Whole body as a byte array | Small images, downloads |
ReadAsStreamAsync | Task<Stream> | One buffer at a time | Large downloads, streaming JSON, processing bytes as they arrive |
ReadAsStringAsync is by far the most common. It pulls the entire body, decodes it as text using the content-type's charset (defaulting to UTF-8), and returns a single string. It's simple and fits most REST APIs, where responses are small enough that the cost doesn't matter.
For larger responses, especially downloads, use a stream.
Two things are different here. HttpCompletionOption.ResponseHeadersRead tells HttpClient to return as soon as the response headers are in, without buffering the whole body first. The body then streams through incoming straight into the file via CopyToAsync. The application never holds more than a single buffer of the file in memory at a time.
ReadAsStringAsync materializes the entire body as a string. A 200 MB JSON response becomes a 400 MB managed allocation (UTF-16 doubles ASCII bytes). For anything bigger than a few MB, switch to ReadAsStreamAsync and process the body incrementally.
The default HttpCompletionOption is ResponseContentRead, which buffers the full body before GetAsync returns. That's fine for typical API calls. For downloads, switch to ResponseHeadersRead, otherwise the streaming benefit is lost.
Most modern HTTP APIs speak JSON, and most JSON serialization in modern .NET goes through System.Text.Json. The System.Net.Http.Json namespace combines the two with extension methods that read and write JSON directly through HttpClient. There's no need to construct StringContent by hand or call JsonSerializer.Deserialize after pulling the string out.
GetFromJsonAsync<T> does four things in one call: it sends the GET, checks the status, reads the body as a stream, and deserializes the JSON into a T. If the status isn't successful, it throws. If the body can't be deserialized, it throws a JsonException. The return type is nullable because a 204 No Content response deserializes to null.
Posting JSON is also compact.
PostAsJsonAsync serializes the object to JSON, sets Content-Type: application/json; charset=utf-8, and POSTs it. ReadFromJsonAsync does the inverse on the response. Together they replace a half-dozen lines of StringContent plus manual JsonSerializer calls.
The PutAsJsonAsync extension method is the matching helper for PUT. There's no DeleteAsJsonAsync because DELETE traditionally has no body, though one can be built with SendAsync and HttpRequestMessage if a particular API requires it.
GetFromJsonAsync streams the response and deserializes from the stream, which avoids allocating the body as a string first. For large JSON payloads it's noticeably cheaper than ReadAsStringAsync followed by JsonSerializer.Deserialize. For small payloads the difference is negligible.
The JSON helpers also accept a JsonSerializerOptions argument when an API uses non-default naming or a custom converter is needed.
The server returns {"id":42,"name":"Wireless Headphones","price":79.99,"stock":120} with snake-case keys, and the snake-case naming policy maps them to the PascalCase property names on the record. Configuring this once and reusing the options object is the standard pattern.
HTTP headers carry metadata about the request and response: authentication tokens, content negotiation hints, caching directives, custom application headers. HttpClient allows setting headers in three places.
The first is on the client itself, through DefaultRequestHeaders. Anything set there is sent with every request through that client. This is the place for headers that don't change per call, like an API key or a User-Agent.
The strongly-typed accessors like Authorization, Accept, and UserAgent validate the value as it's set. Putting garbage into Authorization throws a FormatException at the call site, which is friendlier than failing on the server with a 401.
For headers that change per request, like a request-id, build an HttpRequestMessage and put them on request.Headers.
Custom headers (X-Request-Id, X-Tenant) use the untyped Headers.Add(name, value) API. The strongly-typed ones (Authorization, Accept, IfNoneMatch, and others) are also available through properties on request.Headers for headers that should vary per call.
The third place is on the content itself: Content-Type, Content-Length, and Content-Encoding belong to the request body, not the request as a whole. StringContent and the JSON helpers set these automatically. For hand-built content, set them via content.Headers.
The split between request.Headers and content.Headers follows the HTTP spec strictly. HttpClient throws InvalidOperationException for putting a content header (Content-Type) on the request, or vice versa. The error message is direct.
Most GET endpoints take query parameters: /products?category=electronics&minPrice=20&inStock=true. Building these by hand with string.Format or interpolation works, but it's easy to forget to URL-encode values that contain spaces, ampersands, or non-ASCII characters. UriBuilder (combined with HttpUtility.ParseQueryString or QueryHelpers.AddQueryString) is safer.
HttpUtility.ParseQueryString returns a NameValueCollection that URL-encodes values automatically on ToString(). The spaces in "noise cancelling headphones" become +, which is the legal encoding for spaces in query strings.
For .NET 6+ projects that don't reference System.Web, the equivalent helper is Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString. It returns a string with the query parameters appended and encoded.
Either approach produces correct encoding. The rule is to avoid building URLs by string concatenation when user-supplied data is involved. A search term like "100% cotton" produces a broken URL with manual concatenation, since % has a special meaning in URL encoding.
Uri is the type returned from these builders. It has a few properties that come up often: Uri.Host, Uri.AbsolutePath, Uri.Query, and the IsAbsoluteUri property when combining a base address with a relative path.
By default, HttpClient waits 100 seconds for a request to finish before giving up. That's a long time. For a backend service, failing much faster is usually preferable, so a slow upstream doesn't tie up resources in the application.
HttpClient.Timeout sets the limit per instance. It applies to the whole request, from sending the first byte to receiving the last.
Output (when the endpoint takes longer than 5 seconds):
The timeout shows up as a TaskCanceledException with a TimeoutException as the inner exception (this inner exception was added in .NET 5). On earlier targets, the inner exception is null, and "timed out" must be distinguished from "user-cancelled" some other way.
The hard part with HttpClient.Timeout is that it's a per-instance setting. A single HttpClient shared across the whole app means every request through it shares the same timeout. For finer control, use a CancellationToken per request.
The CancellationTokenSource starts a 2-second timer the moment it's constructed. When the timer fires, the token's IsCancellationRequested flips to true, and the request aborts. The exception type is OperationCanceledException (or its TaskCanceledException subclass), the same shape you get from any cancellable async operation.
Cancellation tokens also let users abort requests early. In a desktop app, a "Cancel" button calls cts.Cancel(). In an ASP.NET Core controller, HttpContext.RequestAborted is a token that flips when the client disconnects; pass it into outgoing HttpClient calls so they stop wasting work when the original caller has already given up.
Setting HttpClient.Timeout to a small value (say, 5 seconds) is fine for most APIs, but it applies to every request through that client. Slow uploads of large files hit it too. For uploads or downloads, either bump the timeout for that client or use CancellationToken per request and leave the client default high.
HTTP status codes fall into five families. Three of them matter day to day.
| Range | Family | Meaning | Examples |
|---|---|---|---|
| 1xx | Informational | Provisional response | 100 Continue, 101 Switching Protocols |
| 2xx | Success | Request worked | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirection | Look somewhere else | 301 Moved Permanently, 304 Not Modified |
| 4xx | Client error | Caller did something wrong | 400 Bad Request, 401 Unauthorized, 404 Not Found, 429 Too Many Requests |
| 5xx | Server error | The server failed | 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable |
There are two patterns for handling them. Pick the one that matches how you want to react.
Pattern 1: throw on non-success. Use EnsureSuccessStatusCode. The code reads cleanly because the success path doesn't branch.
Output (when product 9999 doesn't exist):
HttpRequestException has a StatusCode property (added in .NET 5) that holds the status code for failed requests. For older targets, the message string must be parsed, which is brittle.
Pattern 2: branch on the status. Use IsSuccessStatusCode (or check (int)response.StatusCode directly). No exception is thrown for 4xx or 5xx.
The switch-on-status pattern fits when different status codes mean different things to the application. A 404 might be an expected "not found" that returns null; a 429 means "slow down and retry"; a 5xx means "the server is unhealthy, maybe retry, maybe alert someone". Throwing on all of them forces a catch-and-inspect step that's less direct than branching up front.
The exception flow for a typical failure:
The shape is simple: send, look at the status, decide whether to read the body or fail. The two patterns above are two ways of writing the decision.
HttpRequestException is also thrown for transport-level failures: DNS lookup failed, TCP connection refused, TLS handshake failed. For those cases there's no response and ex.StatusCode is null. A robust caller catches HttpRequestException to cover both shapes.
Retries belong in this same area, but they deserve their own treatment. For naive cases, wrapping GetAsync in a for loop with Task.Delay works; for production-grade retry policies with exponential backoff and circuit breaking, the standard library is Polly, which HttpClientFactory integrates with.
The pieces above come together in a small end-to-end flow. The example creates a product, fetches it back, updates the price, and finally deletes it. It uses JSON helpers throughout, EnsureSuccessStatusCode for the happy path, and a CancellationToken to bound the whole flow at 10 seconds.
A few details. The same client instance handles all four calls; the connection to api.example-store.com is established once and reused. The bearer token is set once on DefaultRequestHeaders and applied to every request. The cancellation token is passed into every call, so if the whole flow takes longer than 10 seconds, all in-flight requests abort together. The with expression on the record creates a copy with one field changed, the idiomatic shape for "update this property."
The lifecycle of one of those requests, end to end, has a few moving parts.
HttpClient is the public surface. The actual work happens in the HttpMessageHandler underneath, which owns the connection pool. The pool either reuses an existing TCP connection or opens a new one, performing DNS resolution and TLS handshake along the way. The server's response comes back through the same pipeline as an HttpResponseMessage. Sharing one HttpClient across many requests is what keeps the connection pool warm.
The lesson started with a warning about creating an HttpClient per request. The reverse approach (one static singleton for the life of the app) fixes socket exhaustion, but introduces a different problem: the underlying handler caches DNS results indefinitely. If the IP address of the service changes (a failover, a deployment, a load balancer reconfiguration), the singleton keeps trying the old IP until the process restarts.
That code looks reasonable, and for short-lived processes or services with stable DNS it's mostly fine. For long-running servers calling endpoints whose IPs can change, it's a slow-burn bug that surfaces hours or days after a backend deployment.
The recommended pattern for non-trivial applications, especially server applications, is IHttpClientFactory. The factory returns HttpClient instances backed by a pool of message handlers it manages, and it rotates handlers on a schedule (every two minutes by default), which keeps both DNS fresh and the socket pool healthy.
The takeaway: don't new HttpClient() per request, and don't blindly cache one static instance for years either. There's a middle path.
10 quizzes