Back to News & Insights
Web Development October 15, 2026 · 8 min read

Twitter Added id_str in 2010 Because JavaScript Can't Hold a Java long

Twitter shipped every ID twice so JavaScript wouldn't round them. Your Spring or Ktor Long IDs hit the same wall in TypeScript, silently. The fixes.

Twitter Added id_str in 2010 Because JavaScript Can't Hold a Java long

Book: Kotlin and Java to TypeScript Also by me: The TypeScript Library — the 5-book collection My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub

On October 18, 2010, Twitter told every developer on its API that tweet IDs were about to change shape. A new generator called Snowflake would produce 64-bit IDs, and TechCrunch's write-up of the notice put the date plainly: "on November 26, these IDs will go longer than 53 bits." The fix Twitter shipped was blunt. Starting October 22, every ID in a JSON response would come back twice, once as a number and once as a string. In the words of developer advocate Matt Harris, quoted in the same article: "To allow javascript and JSON parsers to read the IDs we need to include a string version of any ID."

That string field is idstr, and it is still in the API. The current X developer docs tell v1.1 clients to "always use idstr", and v2 skipped the number entirely: IDs come back as strings by default.

Sixteen years later, the same bug still ships. A Spring Boot or Ktor service returns a Long ID, a TypeScript client calls JSON.parse, and the last few digits change. No exception. No warning in the console. The client then sends the wrong ID back to the server, and you get a 404 on a record that exists, or a hit on a different record whose ID happens to be the rounded value.

Snowflake was built for Twitter's move from MySQL to Cassandra. The original README is direct about the motive: "there is no sequential id generation facility in Cassandra, nor should there be." The requirements were uncoordinated generation across machines, rough time ordering, and IDs that stay under 64 bits.

The layout it settled on: 41 bits of millisecond timestamp against a custom epoch ("gives us 69 years") 10 bits of machine ID, for up to 1024 machines 12 bits of sequence number, rolling over every 4096 IDs per machine

That is 63 bits of payload, which fits a signed 64-bit integer: a Java long, a Kotlin Long, a Scala Long. The Java Language Specification gives long the range -9223372036854775808 to 9223372036854775807. Every JVM backend can hold a Snowflake ID without thinking about it. The trouble starts when the ID leaves the JVM.

A JVM long has 64 bits to spend. A JavaScript number has 53 for the integer part.

A JavaScript number is an IEEE 754 double. MDN's page on Number.MAXSAFEINTEGER spells out the consequence: a double "only has 52 bits to represent the mantissa," plus one implicit leading bit, so it can only safely represent integers between -(2^53 - 1) and 2^53 - 1. That ceiling is 9007199254740991.

Past it, a double still exists for every power of two, but not for every integer. Just above 2^53 the gap between neighbouring doubles is 2. Just above 2^60 it is 2^(60-52), which is 256. Modern Snowflake IDs live up there, so a parsed ID snaps to the nearest representable double, and two real IDs generated a moment apart can land on the same one.

MDN shows the same collapse from the arithmetic side: Number.MAXSAFEINTEGER + 1 === Number.MAXSAFEINTEGER + 2 evaluates to true. The X docs use 10765432100123456789, which is past even a Java long's ceiling, and show it printing back as 10765432100123458000.

None of this breaks the JSON spec. RFC 8259, section 6 "allows implementations to set limits on the range and precision of numbers accepted," and names only the integers in [-(2^53)+1, (2^53)-1] as the range where implementations "will agree exactly on their numeric values." Jackson writing 9007199254740993 is valid JSON. JSON.parse rounding it is also valid. The contract between them is the bug.

TypeScript does not help here. JSON.parse returns any, your as { id: number } is a promise you made to the compiler, and the value already satisfies number. The type system checks the shape, and the shape is fine.

The natural reaction from a JVM developer is: JavaScript has arbitrary-precision integers now, so parse into BigInt. Two things get in the way.

First, JSON.parse never produces a BigInt. Numbers become doubles during parsing, before your code sees anything. The MDN JSON.parse page is explicit that the reviver "is run after the value is parsed," so numbers "will have already been converted to JavaScript numbers, and may lose precision in the process." Writing BigInt(value) inside a classic two-argument reviver gives you an exact BigInt of the wrong number.

Second, the trip back is closed by default. Per MDN's BigInt page, calling JSON.stringify() on any value containing a BigInt throws a TypeError unless you supply a toJSON or a replacer.

So BigInt is a fine type to hold an ID once you have the exact digits. It cannot recover digits the parser already threw away. The digits have to survive the parse, which means either they arrive as a string, or the parser hands you the source text.

The fix Twitter chose, and the one Discord documents for its own Snowflake IDs ("they are always returned as strings in the HTTP API"), is to put the ID on the wire as a JSON string. Keep Long in the database and in your domain model. Change only the serialized form.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation