System.Text.Json.JsonException: '<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.
means the response body starts with <: the server sent an HTML page, not JSON.
The URL is usually wrong and lands on a page meant for a browser, such as a single-page app's
index.html.
Read the body once as a string to see what came back, fix the path, and keep a check on the status code
and the Content-Type before you deserialize, so the next wrong URL fails with a message
that says so.
The error
Unhandled exception. System.Text.Json.JsonException: '<' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.
---> System.Text.Json.JsonReaderException: '<' is an invalid start of a value. LineNumber: 0 | BytePositionInLine: 0.
at System.Text.Json.ThrowHelper.ThrowJsonReaderException(Utf8JsonReader& json, ExceptionResource resource, Byte nextByte, ReadOnlySpan`1 bytes)
It came from one call with a typo in the path, order instead of orders:
var order = await client.GetFromJsonAsync<Order>("api/order/1");
Why it happens
GetFromJsonAsync checks the status code before it reads the body. When a path answered
404, the call threw Response status code does not indicate success: 404 (Not Found).
instead. So this error means the server said 200 OK and sent something that is not JSON. The
Path: $ and BytePositionInLine: 0 confirm the reader gave up on the very first
byte.
The test API served a single-page app with MapFallbackToFile("index.html"), the usual
setup when ASP.NET Core hosts the front end and the API together. The mistyped path matched no endpoint,
fell through to the fallback, and came back as a 200 with the app's HTML. Anything else that answers a
wrong URL with 200 and a web page gives the same message; the test covered the fallback case.
The fix
First look at what the server really sent:
using var response = await client.GetAsync("api/order/1");
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"{(int)response.StatusCode} {response.Content.Headers.ContentType}");
It printed 200 text/html and <!DOCTYPE html>, which points straight at the
path. Then make the client refuse anything that is not a successful JSON response:
static async Task<T?> GetJson<T>(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (!response.IsSuccessStatusCode || mediaType != "application/json")
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"GET {path} returned {(int)response.StatusCode} {mediaType}, not JSON: {body[..Math.Min(body.Length, 60)]}");
}
return await response.Content.ReadFromJsonAsync<T>();
}
api/orders/1: Order { Id = 1, Customer = Maria Garcia, Total = 42.50 }
api/order/1: GET api/order/1 returned 200 text/html, not JSON: <!DOCTYPE html>
The correct path deserialized as before; the wrong one now says what it got and where.
EnsureSuccessStatusCode() alone would not have caught this case, because the status was 200;
the Content-Type check is what does. If your API answers application/problem+json
for errors, allow that media type too.
How it was reproduced
An empty web project (dotnet new web) with a wwwroot/index.html and a fallback,
listening on 127.0.0.1:15810, and a console client (dotnet new console) using
System.Net.Http.Json from the shared framework, no packages. .NET SDK 10.0.403, runtime 10.0.14, Windows
11. The Order record, the paths and the names are the test's own.
Frequently asked
- What does '<' is an invalid start of a value mean?
- The JSON reader found a < as the first character of the response, which means the server sent HTML. Usually the URL is wrong and the server answered it with a web page, such as a single-page app's index.html fallback.
- Why does GetFromJsonAsync not throw a 404 for a wrong URL?
- It does when the server answers 404. If a single-page app fallback answers the wrong URL with 200 and HTML, the status check passes and the JSON reader fails on the < instead.
- How do I see the HTML that HttpClient received?
- Call GetAsync, then ReadAsStringAsync on the content and print the status code, the Content-Type header and the first characters of the body. That shows which page answered.
A different JSON failure, a string where a number belongs, is in The JSON value could not be converted to System.Int32. More decoded errors in the Fixes category.