DEV Community

Cover image for The Same Seed Picked Minute 13 on the Phone and 18 on the Web
K M Shahriar Hossain
K M Shahriar Hossain

Posted on Originally published at devshakib.jumyn.com

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

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);
Enter fullscreen mode Exit fullscreen mode

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.

compiler where it runs minute
AOT (dart compile exe) Android and iOS release builds 13
JIT (dart run) debug builds, tests 13
dart2wasm Flutter web built with --wasm 13
dart2js Flutter web, the default build 18

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:

expression VM, AOT dart2wasm dart2js
'2026-09-29'.hashCode 160143713 160143713 374027133
7.hashCode 81207 81207 7
true.hashCode 1231 1231 519018
3.5.hashCode 3377700795056128 786432 349197722
DateTime.utc(2026, 9, 29).hashCode 487035471 487035471 518150374
Object.hash(1, 2) different every run different every run different 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

final minute = stableHash(day) % 60;
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode
dart test -p vm
dart test -p chrome
dart test -p chrome -c dart2wasm
Enter fullscreen mode Exit fullscreen mode

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.


Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.

Top comments (1)