Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 095 / 100

Precision loss in large IDs: what developers should know

Some IDs exceed what JavaScript numbers can hold.

Expona Admin7 min read

Precision loss in large IDs: a silent killer in distributed systems

You ship an API that returns user IDs from your database. Everything works in development. Then a customer with ID 9223372036854775807 reports that their account is being confused with another user's. You check the logs and see the ID has been silently rounded to 9223372036854776000. Welcome to the world of precision loss in large IDs—a bug that's easy to miss because JavaScript doesn't throw an error; it just corrupts your data.

This isn't a theoretical edge case. If you're building any system that handles large distributed IDs—Snowflake IDs, distributed database keys, or any 64-bit identifier—you need to understand how JavaScript's number type betrays you and how to fix it before it reaches production.

Why Snowflake and 64-bit IDs exceed JavaScript's safe range

JavaScript numbers are 64-bit floating-point values (IEEE 754 double-precision). Of those 64 bits, only 53 are dedicated to the significand (the mantissa)—the part that actually holds the magnitude of the number. The remaining bits store the sign and exponent.

This means JavaScript can safely represent integers up to 253 − 1, which is 9,007,199,254,740,991. Any integer larger than that cannot be represented exactly.

Snowflake IDs, commonly used in distributed systems like Twitter's original ID generation, are 64-bit numbers composed of:

  • 41 bits for timestamp (milliseconds since epoch)
  • 10 bits for worker/datacenter ID
  • 12 bits for sequence number

Since they use all 64 bits, they regularly exceed 253. A typical Snowflake ID like 1234567890123456789 is well beyond JavaScript's safe integer limit. When you parse it, JavaScript silently rounds it, corrupting the ID without warning.

The same problem affects any ID scheme that uses 64-bit integers: distributed databases, trading systems, financial platforms, and large-scale SaaS products. If you're running on AWS or using services like Stripe, you're working with IDs that can easily exceed this limit.

JSON.parse silently rounds large numbers

The most dangerous aspect of this issue is its silence. JSON.parse doesn't throw an error when it encounters a large number. It simply converts it to the nearest representable JavaScript number, which is often wrong.

typescript
const jsonString = '{"id": 9223372036854775807, "name": "user"}';
const parsed = JSON.parse(jsonString);
console.log(parsed.id); // 9223372036854776000 (rounded!)
console.log(parsed.id === 9223372036854775807); // false

In this example, the ID 9223372036854775807 (which is 263 − 1, the maximum signed 64-bit integer) gets rounded to 9223372036854776000. Your application now has a corrupted ID, and you won't know it until that ID gets compared, stored, or sent back to the database.

Imagine an API response containing order IDs. Your payment system receives an order ID, stores it, processes it, and sends back a response. But because the ID was rounded during JSON parsing, the database can't find the original order. Customer support gets flooded with "order not found" tickets, and you're left debugging a problem that looks like a database issue but is actually a JavaScript limitation.

The core problem: there's no exception, no warning, no console.error. JSON.parse succeeds silently, and the corruption propagates downstream.

Send large IDs as strings

The solution is straightforward but requires discipline: transmit large IDs as strings in JSON, never as numbers.

typescript
// ❌ Wrong: ID as number
const response1 = {
  id: 9223372036854775807,
  username: "alice"
};

// ✅ Correct: ID as string
const response2 = {
  id: "9223372036854775807",
  username: "alice"
};

const parsed = JSON.parse(JSON.stringify(response2));
console.log(parsed.id); // "9223372036854775807" (exact, no rounding)
console.log(typeof parsed.id); // "string"

When you send an ID as a string, JSON.parse preserves it exactly. The string "9223372036854775807" is transmitted and received without any numeric conversion, so no precision loss occurs.

On the receiving end, you handle it as a string when you need to compare or reference it, and convert it only when necessary for operations that genuinely require numeric operations.

typescript
// When receiving an ID from an API
const userId: string = response.userId; // Store as string

// If you need to pass it to a database query
const query = `SELECT * FROM users WHERE id = $1`;
db.query(query, [userId]); // Pass the string directly

// For comparison operations
if (userId === expectedId) {
  // Both are strings, so comparison is safe
}

This approach is particularly important in NestJS and Node.js APIs. When you serialize database models to JSON responses, ensure that ID fields are explicitly converted to strings:

typescript
// In a NestJS service or database layer
class UserEntity {
  id: bigint; // or string from database
  email: string;
  
  toJSON() {
    return {
      id: this.id.toString(), // Convert to string for JSON
      email: this.email
    };
  }
}

// Or use a custom JSON serializer
@Entity()
export class User {
  @PrimaryColumn('bigint')
  id: string; // Store as string in TypeORM/Sequelize
  
  @Column()
  email: string;
}

Check API responses carefully

Prevention starts with auditing. If you're consuming external APIs or returning data from your own backend, verify that large ID fields are strings, not numbers.

When reviewing API documentation or responses, look for:

  1. Any ID that could be 64-bit (especially from distributed systems, trading platforms, or databases)
  2. Numeric IDs in JSON responses that exceed 253
  3. Fields named id, userId, orderId, transactionId, etc., that might be large

A practical check: if you're unsure whether an ID might be large, convert it to a string. There's minimal downside to treating all IDs as strings, but significant risk in assuming they're small.

typescript
// Defensive parsing with explicit type checking
interface ApiResponse {
  id: string; // Always expect IDs as strings
  data: unknown;
}

function parseApiResponse(json: string): ApiResponse {
  const parsed = JSON.parse(json);
  
  // Validate that ID is a string
  if (typeof parsed.id !== 'string') {
    throw new Error('ID must be a string to avoid precision loss');
  }
  
  return parsed;
}

In production systems I've worked on—especially e-commerce and trading platforms—I've seen this issue manifest as intermittent, hard-to-reproduce bugs. A user's order ID gets rounded, their transaction is lost in logs, or a trade is matched to the wrong account. By the time you trace it back to JSON parsing, you've wasted hours.

Document this requirement in your API contracts. If you're building a REST API, make it explicit in your OpenAPI/Swagger schema that IDs are strings. If you're using GraphQL, ensure scalar definitions for IDs are serialized as strings.

Common mistakes

Mistake 1: Assuming numbers are safe because they look small in development. Your dev environment might only generate IDs up to 250, which look fine in the console. But production systems with months of data or high-volume ID generation will cross the threshold. Always code defensively.

Mistake 2: Trusting that JSON.parse or JSON.stringify will handle it. They won't—both silently corrupt large numbers. The responsibility is on you to send IDs as strings from the start.

Mistake 3: Converting IDs to numbers after parsing for "convenience." Once you parse "9223372036854775807" as a string, don't convert it to a number to use it. If you need numeric operations, use BigInt:

typescript
const idString = "9223372036854775807";
const idBigInt = BigInt(idString); // Safe conversion
const comparison = idBigInt > 1000n; // Numeric operations with BigInt

Mistake 4: Not validating API responses from third parties. If you integrate with Stripe, Twitter API, or any distributed system, assume their IDs are strings and validate that assumption. Add tests that explicitly check ID types.

Key takeaways

  • JavaScript can safely represent integers only up to 253 − 1; larger numbers are silently rounded by JSON.parse
  • Snowflake IDs and other 64-bit identifiers commonly exceed this limit, making them unsafe as JSON numbers
  • Always transmit and receive large IDs as strings, never as numbers, in JSON payloads
  • Use BigInt only when you need actual numeric operations on large values; for identity comparisons, keep IDs as strings
  • Document ID types in API contracts and add tests to catch precision loss before production
  • When integrating with external APIs, validate that ID fields are strings and log warnings if they're numbers
  • This issue is silent—JSON.parse won't warn you, so defensive coding and audits are essential
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles