devShakib

Loading devShakib…

The Same Seed Picked Minute 13 on the Phone and 18 on the Web

Random(seed) matches on every Dart compiler. The seed doesn't: hashCode and big integers change on the web, and one Flutter web build gives both answers.

A dev.to thread last week was about daily reminders: fire one at a random minute each afternoon, but make the minute the same for a given day, so the schedule can be rebuilt as often as you like without a reminder appearing to move. The standard trick is a seeded generator:

final minute = Random('2026-09-29'.hashCode).nextInt(60);

Same date, same seed, same minute. I compiled that one line four ways on Dart 3.13.4 and ran each on a Mac: the native builds directly, the JavaScript and Wasm output under Node, and later all of it again in Chrome.

compilerwhere it runsminute
AOT (dart compile exe)Android and iOS release builds13
JIT (dart run)debug builds, tests13
dart2wasmFlutter web built with --wasm13
dart2jsFlutter web, the default build18

The phone and the website disagree about the date.

Random isn't the problem

The first suspect is Random, and it's innocent. Random(42) produced the same sequence, 587, 758, 4, 885, 77, under all four compilers, and so did every seed I tried from −2³¹−1 up to 2³³, along with List.shuffle(Random(7)).

What changed was the seed. '2026-09-29'.hashCode is 160143713 on the VM and on Wasm, and 374027133 when compiled to JavaScript. It isn't only strings:

expressionVM, AOTdart2wasmdart2js
'2026-09-29'.hashCode160143713160143713374027133
7.hashCode81207812077
true.hashCode12311231519018
3.5.hashCode3377700795056128786432349197722
DateTime.utc(2026, 9, 29).hashCode487035471487035471518150374
Object.hash(1, 2)different every rundifferent every rundifferent every run

None of this is a bug. Object.hashCode is documented as something that "need not be consistent between executions of the same program", and Object.hash says outright that it may depend on values that change on each run, and every run here gave a new number. A hash code exists to put things in a Map during one execution. It was never a fingerprint.

What makes it slip through is that the VM happens to be stable for strings: same value every run, so every test on your machine and every run on your phone agrees with itself. 'a'.hashCode even matches on all three. The difference only shows up on the build you probably tested least.

So hash it yourself — and it breaks again

The obvious fix is to stop borrowing hashCode and hash the bytes. FNV-1a is a few lines:

int fnv1a(String s) {
  var h = 0x811c9dc5;
  for (final b in utf8.encode(s)) {
    h ^= b;
    h = (h * 0x01000193) & 0xffffffff;
  }
  return h;
}

Phone: minute 7. dart2js: minute 24.

On the web, a Dart int is a JavaScript number, and Dart's number documentation spells out the two consequences. Integers "lose precision past 53 bits", and for bitwise operators "the operands are truncated to 32-bit integers". Here the multiply is the problem: a 32-bit hash times a 25-bit prime is a 57-bit product, and the low bits, the only ones the mask keeps, are exactly the ones JavaScript rounded away.

Shifts fail the same way. 1 << 40 is 1099511627776 on the phone and 0 on the web, so a seed built with a shift was never the same number in the first place.

One web app, two answers

The table's last two rows are both Flutter web, and a --wasm build doesn't choose between them — the browser does. A --wasm build compiles to JavaScript as well, and Flutter's loader decides which one to start. In Flutter 3.47, flutter.js ships a default allow-list of {blink: true, gecko: false, webkit: false}: Chrome and Edge get the Wasm build, and Safari, Firefox and every browser on iOS get the JavaScript one.

So the same deploy, at the same URL, picks minute 13 for someone in Chrome and minute 18 for someone on an iPhone. Without --wasm it's 18 for everyone on the web and 13 in the app, which is at least consistent within each.

That matters well beyond reminders: A/B bucketing by user id, a daily puzzle seeded by the date, procedurally generated levels, shuffled quiz order, a cache key or shard derived from a string. Anything where two devices are meant to agree.

What held

Two versions gave the same answer under all four compilers.

A real hash from package:crypto, which does its arithmetic in 32-bit pieces on purpose:

int stableHash(String s) {
  final d = sha256.convert(utf8.encode(s)).bytes;
  return (d[0] << 24 | d[1] << 16 | d[2] << 8 | d[3]) & 0x7fffffff;
}

Or FNV-1a with every intermediate kept under 2⁵³, by multiplying the low and high halves separately:

int fnv1a(String s) {
  var h = 0x811c9dc5;
  for (final b in utf8.encode(s)) {
    h = (h ^ b) & 0xffffffff;
    h = ((h & 0xffff) * 0x01000193 +
            (((h >>> 16) * 0x0193 & 0xffff) << 16)) &
        0xffffffff;
  }
  return h;
}

Then take the value from the hash, not from a generator seeded with it:

final minute = stableHash(day) % 60;

Random(seed) does agree across compilers today, but its own documentation says "the implementation of the random stream can change between releases of the library." An SDK upgrade could move every reminder. A hash reduced with % can't.

Test it where it breaks

A golden value catches all of this, but only if the test runs on each compiler. Pin the answer once and run the same file three ways:

test('the minute for a date is fixed', () {
  expect(minuteFor('2026-09-29'), 53); // pinned from one VM run
});
dart test -p vm
dart test -p chrome
dart test -p chrome -c dart2wasm

With hashCode or the first FNV-1a, the VM and Wasm runs pass and the Chrome run fails, with Expected: <53> and Actual: <33> for hashCode. With either of the versions above, all three pass. -p vm alone passes everything, which is how this ships.