Why a .NET long ID loses its last digits in Angular, and how to keep them

The ID the .NET API sent, 1234567890123456789, above the ID the Angular page showed, 1234567890123456800, with the last three digits highlighted. Underneath, the same ID sent as a string arrives unchanged.
COMPANION REPO · LIVE FROM GITHUB

The API sent 1234567890123456789. The Angular page showed 1234567890123456800. JavaScript reads every JSON number as a double, and above 9,007,199,254,740,991 a double can't hold every integer, so the last digits change on the way in and nothing complains.

If an ID can go past that line, I'd make it a string in the API contract, with one attribute on the .NET property. Reading the original digits in the browser is a fallback for APIs you can't change. That's where I ended up after watching it happen between a .NET 10 API and an Angular 22 page.

What the page gets, and what happens on the way back

The API returns two orders. One has a snowflake-style ID, the kind that packs a timestamp into a 64-bit number. The other has ID 42. System.Text.Json writes a C# long as a plain JSON number, so the response carries "id":1234567890123456789.

Angular's HttpClient handed the page a number, and it was 1234567890123456800. The 42 came through fine. Then the page sent the ID back, once in a URL and once inside the order in a PUT body. Both got a 404. There was no exception in the browser and no error on the server, which parsed a perfectly valid long. It just wasn't the ID it had sent.

That 404 is the lucky outcome. Doubles this big are 256 apart, so a run of neighbouring IDs all land on the same number. If the rounded value happens to be another order's ID, the PUT updates that order instead.

THE SAME ID, AS A NUMBER AND AS A STRING
"id": 1234567890123456789 · A JSON NUMBER
.NET APIAngular page
1234567890123456789
JSON.parse keeps the nearest double, 1234567890123456768, and prints it as …800
send it back: GET /raw/orders/1234567890123456800 → 404
"id": "1234567890123456789" · A JSON STRING
.NET APIAngular page
"1234567890123456789"
send it back: GET /orders/1234567890123456789 → 200
Above 9,007,199,254,740,991 (2^53 − 1) a double can't hold every integer, and nothing throws when the digits change. One [JsonNumberHandling] attribute on the .NET property sends the ID as a string, and the string arrives whole.

It happens in JSON.parse, and HttpClient has no hook

I wanted to be sure Angular wasn't doing anything odd, so I ran the same text through JSON.parse in Node 26 and printed the ID three ways:

JSON.parse id:                     1234567890123456800
the double it really holds:        1234567890123456768
Number.MAX_SAFE_INTEGER:           9007199254740991

MAX_SAFE_INTEGER is 2^53 - 1, the last point where every integer still has its own double. The second line is the odd one. The double in memory is ...768, but JavaScript prints the shortest decimal that maps back to it, which is ...800. Neither is what the server sent.

You can't fix this inside HttpClient. In Angular 22.2 both backends, fetch and XHR, call JSON.parse(text) with no reviver, and short of replacing the whole backend, nothing in provideHttpClient swaps the parser (fetch.ts). An interceptor sees the body after it's parsed, which is too late.

The fix goes on the .NET property

System.Text.Json has a JsonNumberHandling attribute for this (docs). On a positional record it needs the property: target:

public record Order(
    [property: JsonNumberHandling(
        JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)]
    long Id,
    string Customer,
    decimal Total);

Now the same endpoint writes this:

[{"id":"1234567890123456789","customer":"Snowflake Ltd","total":19.99},{"id":"42","customer":"Identity Co","total":5.50}]

The small ID becomes "42" too, which is what you want: one type for the field, whatever the value. The total stays a number. On the Angular side the model says id: string, and the GET and the PUT both came back 200.

You could set NumberHandling once on the app's JSON options instead. I tried it, and total became "19.99", along with every other price and count in the API. A client that adds to a total then gets string concatenation, because "19.99" + 1 is "19.991". That's too wide a fix. The problem is one kind of field, so the fix belongs on that field.

The .NET side surprised me twice. The unchanged API already accepted "id":"1234567890123456789" in a PUT body, because ASP.NET Core uses the web defaults, and those read numbers from strings. So the reading half was fine all along and only the writing half needed the attribute. I kept AllowReadingFromString on it anyway, so the record reads back the same with plain serializer options in a test or a background job.

The other surprise was OpenAPI. Microsoft.AspNetCore.OpenApi gives the ID "type": ["integer", "string"] with or without the attribute, because it describes what the server reads. A generated TypeScript client will type it number | string either way, so someone has to tell the Angular side.

When the API isn't yours to change

Sometimes the API belongs to another team or a vendor. JSON.parse can help there now. Its reviver, the callback that runs on every value, gets a third argument whose source is the original text of that value (MDN). So you ask HttpClient for text and parse it yourself:

export function parseWithExactIds(text: string, keys: readonly string[] = ['id']) {
  return JSON.parse(text, (key, value, context) =>
    keys.includes(key) && typeof value === 'number' ? context.source : value);
}

// in the component
const text = await firstValueFrom(this.http.get('/raw/orders', { responseType: 'text' }));
const orders = parseWithExactIds(text) as Order[];

The page got "1234567890123456789", and both requests back returned 200. I match on key names on purpose. A rule like "any unsafe integer becomes a string" would also catch a large amount or a value like 1e300.

Chrome 114, Firefox 135, Safari 18.4 and Node 21 all support it. TypeScript is the snag. Angular 22 uses TypeScript 6, whose types only know the two-argument reviver, so the code above fails with error TS2345. A declaration file of a few lines that adds the overload fixes it. The TypeScript 7.1 beta's es2026 lib ships the same overload, along with JSON.rawJSON (7.1 beta announcement).

I'd keep the IDs as strings in the app and skip BigInt. An ID gets compared, put in a URL and sent back, and nobody adds two of them. A string does all of that, while a BigInt makes JSON.stringify throw "Do not know how to serialize a BigInt". JSON.rawJSON can write exact digits back for an API that insists on a number, but an ASP.NET Core API reads the string just fine.

Who needs this

A bigint identity column doesn't. At 1,000 new rows a second, it would take about 285,000 years to reach 2^53. A snowflake-style ID is the opposite case. The common layout puts a millisecond timestamp in the top bits, shifted left by 22, so the IDs pass 2^53 about 25 days after the generator's start date. Discord's API docs say they send theirs as strings for this reason.

It comes down to where the ID comes from. From a sequence, leave it alone. From a snowflake generator, a random 64-bit value or a hash, put the attribute on the property, make the field a string in the TypeScript model, and tell the frontend team, because the OpenAPI document won't.