Ankit Ranjan
Back to Deep Dives

Doubles in Dart, Part 2 — Memory and IEEE 754 Edge Cases

How Dart actually stores doubles in memory, why doubles are always boxed, and the edge cases — subnormals, catastrophic cancellation, the 53-bit cliff, and rounding modes.

May 22, 2026 7 topics 7 quiz questions
Share:
1

Why doubles are always boxed

In Episode 3 we saw that small integers get a clever optimization — Dart stores them directly inside the pointer slot using the Smi trick, saving a heap allocation entirely. Surely the same trick works for doubles?

Unfortunately, no.

A double needs all 64 of its bits to represent the sign, exponent, and mantissa. There is no spare bit. We can't steal one for a tag without losing information that the double is actively using. So unlike int, a double in Dart is stored as a heap object — every time.

int can be inline. double cannot.int x = 42xvalue: 420✓ Smi — value lives in the pointer slot. Zero heap allocation.double x = 3.14xpointer0xA1B2…1header+ GC tagsvalue8 bytes✗ Always boxed. All 64 bits are needed for the value —no spare bit for a Smi tag.

The cost per double:

Memory: object header plus 8 bytes for the value.
Speed: pointer indirection on every read.
Garbage collection: every double object has to be tracked and eventually freed.

For a single double, this is nothing. For a list of a million doubles, it is exactly the same boxing problem we saw with integers — and it shows up in the same places.

2

Three ways to escape the boxing cost

We have three reliable ways to avoid the cost of boxing doubles.

1. Float64List for collections.

For lots of doubles, Float64List is the workhorse. Values are packed contiguously, no boxing, no pointers between elements.

import 'dart:typed_data';

// A million doubles, exactly 8 MB, no boxing
Float64List samples = Float64List(1000000);
2. FFI for native interop.

When passing doubles to C code via dart:ffi, the values are unboxed at the boundary. A Float64List can be handed to native code by pointer with zero copying — the same memory is seen from both sides.

3. VM optimization in hot paths.

When the optimizer can prove a value is always a double and never escapes to where its identity matters, it keeps the value in a CPU floating-point register or on the stack. We don't write any special syntax for this — it just happens when the JIT or AOT compiler can prove it is safe.

What defeats it:

• Storing the value in a List<double> or List<dynamic>
• Passing it to a function with num or Object parameter type
• Returning it from a function whose declared return type isn't double
• Calling identical() on it

In well-typed hot loops, the optimizer can be very effective. But we can't rely on it. When boxing cost matters and we want a guarantee, reach for Float64List.

3

Subnormal numbers — the slow zone near zero

IEEE 754 has a special encoding for values too small to fit the normal floating-point format. We call them subnormal numbers (or, historically, denormalized).

The normal range of a double runs from about 2.2 × 10⁻³⁰⁸ up to 1.7 × 10³⁰⁸. Below the normal minimum, IEEE 754 reserves a special set of bit patterns to fill the gap — values down to about 5 × 10⁻³²⁴, with steadily decreasing precision as they shrink.

This is great in theory. It gives us gradual underflow: values approach zero smoothly instead of suddenly snapping to it. Without subnormals, the smallest non-zero double would be 2.2 × 10⁻³⁰⁸, and everything tinier would just become zero.

But there is a real-world catch. Many CPUs handle subnormal arithmetic via slow microcode that is 10–100 times slower than normal arithmetic. In audio processing, simulation, and tight numerical loops, this is a known performance trap. A simulation running fine can suddenly slow down when its values drift into the subnormal range.

double tiny = 1e-310;
print(tiny);                  // 1e-310
print(tiny.isFinite);         // true
print(tiny == 0);             // false
// But operations on tiny may run noticeably slower on some hardware.
The standard fixes (in native code or libraries that expose CPU flags):

• Enable "flush to zero" mode so subnormals are silently treated as zero.
• Clamp tiny intermediate values to zero explicitly.

Dart itself doesn't expose CPU-flag control — if we are hitting this problem, we'd typically use FFI to set it from native code. But just knowing the slow zone exists is half the battle.

4

Rounding modes — banker's versus away-from-zero

IEEE 754 defines five rounding modes, and Dart's behaviour involves a couple of subtleties worth knowing.

When the result of an arithmetic operation can't be represented exactly, the CPU picks the nearest representable value. The default IEEE 754 mode is round half to even — also called banker's rounding. When the value is exactly halfway between two representable numbers, the one whose last bit is even wins.

This is what every floating-point operation in Dart uses internally. It is deterministic and reproducible: everyone follows the same rule.

But Dart's .round() method is a different story. It rounds half away from zero:

print((0.5).round());   // 1
print((1.5).round());   // 2
print((2.5).round());   // 3
print((-0.5).round());  // -1
print((-1.5).round());  // -2
Compare with banker's rounding, which would give 0, 2, 2, 0, -2. They agree on 1.5 → 2 (both "away from zero" and "to even" land on 2), but disagree on 0.5 and 2.5.

This is a deliberate Dart language choice. Away-from-zero matches what humans normally mean by "rounding," which is why .round() uses it. Banker's rounding is for arithmetic correctness — it avoids the small statistical bias that always-away-from-zero introduces over many operations.

If we need banker's rounding specifically (often required for accounting standards like IEEE 754, financial regulations, or scientific publications), we'll need to implement it ourselves or use a decimal library that supports it.

5

Catastrophic cancellation and non-associativity

Two classic ways doubles surprise us, beyond the 0.1 + 0.2 case we met in Episode 4.

Catastrophic cancellation. When we subtract two nearly-equal doubles, most of the precision cancels out and we are left with very few meaningful digits.

Catastrophic cancellationSubtracting two nearly-equal doubles destroys most of the precision.a = 1.0000000000000002b = 1.0000000000000000a − b = 0.0000000000000002these 16 digitsare identicalonly one digitof meaning survivesIn Dart, this actually happens:double a = 1.0 + 1e-15;double b = 1.0;print(a - b); // 1.1102230246251565e-15, not 1e-15The difference is mostly rounding noise. Beware of subtraction near equality.

The first 15 digits of a and b were identical. After subtraction those bits cancelled. What remains is mostly rounding noise from the original storage of a.

Floating-point addition is not associative. (a + b) + c is not guaranteed to equal a + (b + c).

double a = 1e20;
double b = -1e20;
double c = 1.0;

print((a + b) + c);  // 1.0
print(a + (b + c));  // 0.0
In the first version, the huge values cancel first, then 1 is added. In the second, 1 is added to a huge value where it is far below the precision threshold — it gets lost entirely.

This affects parallel reductions, sums of long sequences, and anything where the order of operations isn't deterministic. For sums where precision matters, Kahan summation (which keeps a running error correction term alongside the sum) is the standard fix.

6

The 53-bit integer cliff

Dart's int is 64-bit, with a range of about ±9 quintillion. Dart's double has 53 bits of effective precision (52 explicit mantissa bits plus an implicit leading 1).

That is a problem. A double cannot exactly represent every 64-bit integer.

The cutoff sits at 2⁵³ = 9 007 199 254 740 992. Below that, every integer maps to a unique double. Above that, doubles can no longer represent every integer — they start skipping.

The 53-bit precision cliffDoubles can exactly represent every integer up to 2⁵³. Past that, they start skipping.cliff at 2⁵³every integer fits exactlyonly every other integer fits0 … 9 007 199 254 740 9929 007 199 254 740 992 +int big = 9007199254740993; // valid int (2⁵³ + 1)double d = big.toDouble(); // silently rounds to 2⁵³d.toInt(); // = 9007199254740992 ← lost a digit

Where this bites in practice:

Database IDs that exceed 2⁵³ (Twitter snowflake IDs, some auto-increment patterns)
Timestamps in nanoseconds since the epoch
Cryptographic values like hashes and nonces
Big counters from any external system

The fix is simple: keep big numbers as int (or BigInt) and don't pass them through double. If we have to serialise and parse, use string representations or split into high and low halves.

This is also why JavaScript exposes Number.MAX_SAFE_INTEGER at exactly this value — JS only has doubles, no separate integer type, so 2⁵³ is the safe ceiling for integer arithmetic in JS. Dart sidesteps this for int, but the moment we cross over to double, the same ceiling applies.

7

Bit-level inspection

Sometimes we want to see exactly what bits Dart is using to represent a double. The standard tool is ByteData from the typed_data library we met in Episode 3.

import 'dart:typed_data';

double value = 1.0;
ByteData bytes = ByteData(8);
bytes.setFloat64(0, value);

int rawBits = bytes.getInt64(0);
print(rawBits.toRadixString(16).padLeft(16, '0'));
// 3ff0000000000000
That hex string is the IEEE 754 encoding of 1.0:

Sign: 0 (positive)
Exponent: 01111111111 (1023, the bias — representing 2⁰)
Mantissa: all zeros (with the implicit leading 1, the value is 1.0)

We can go the other way too:

// Build a double from raw bits
ByteData bytes = ByteData(8);
bytes.setInt64(0, 0x4000000000000000);  // 2.0 in IEEE 754
double value = bytes.getFloat64(0);
print(value);  // 2.0
This is occasionally useful for:

• Custom binary serialisation formats
• Building hash functions over doubles
• Inspecting NaN payloads (different NaN values have different mantissa bits)
• Sanity-checking our understanding of IEEE 754

Once we can see the bits, every quirk we have discussed in these two episodes becomes mechanical to reason about. Boxing? It is the heap object that wraps these 8 bytes. Precision loss? Bits get truncated to fit 52. Subnormals? Specific patterns in the exponent. NaN? Exponent all ones, mantissa non-zero. It is all there in the bytes.

That wraps up the floating-point story. In the next main episode we move on to strings, and we will see that text is somehow even messier than numbers.

Test your understanding

7 questions

Seven questions covering double memory layout, escape hatches, and the IEEE 754 edge cases — subnormals, rounding, cancellation, and the 53-bit cliff.

Search

Loading search...