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.
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.
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.
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.
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.
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.
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.
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.0In 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.
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.
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.
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.