{"slug": "301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end", "title": "301 duplicate IDs in the browser, 0 on the JVM: one real bug, end to end", "summary": "A developer found that a match-3 game engine generated duplicate gem IDs in the browser but not on the JVM, due to the code relying on System.nanoTime() which browsers clamp for security. The fix replaced the timestamp-based ID with a simple counter, and three new tests ensure the bug cannot recur.", "body_md": "A reader named Ryan left the sharpest comment on my last post. The gist: it was jargon-heavy, kept restating \"you have to test the AI's output\" in new words, and read more like a pitch deck than a case study. He was right, and he pointed at the fix himself: *walk through one real task. What changed, what caught the problem, what proof was required, where did a human step in.*\n\nSo here is one task, start to finish. Everything below is public and runnable. No withheld details, no diagrams of boxes with arrows.\n\nMy match-3 game runs on a plain-Java rules engine. It runs two ways: on a JVM (where the tests and CI live) and in the browser, compiled to JavaScript by [TeaVM](https://teavm.org), so the same Java drives a real playable board.\n\nThat second runtime is not just a demo. Running one piece of logic on two different machines gives me a free check: **where the two disagree, one of them is wrong.** I did not have to write down the right answer. I just had to notice a disagreement.\n\nHere is a task where that check earned its keep.\n\nI ported the engine to the browser. New `demo-js`\n\nmodule, TeaVM config, opaque integer handles in and JSON out so boards never actually cross into JavaScript. Mechanical work. The engine code barely moved.\n\nExcept the port compiled a line I had never once looked at hard. This is how every gem got its ID:\n\n```\nString id = row + \"-\" + col + \"-\" + System.nanoTime() + \"-\" + RNG.nextInt(1000);\n```\n\nTimestamp plus a random number. It had passed every test for months.\n\nThe IDs are how the renderer tells one gem from another. Two gems with the same ID animate as a single gem. So I ran the same board generation on both runtimes and counted collisions over 128,000 gems.\n\nSame source code. Same inputs. Different answer. That is the entire signal. A machine counted it; I did not have to guess that something felt off.\n\nA number this specific still needs a human to say *which* side is wrong and *why*. That part was me.\n\n`System.nanoTime()`\n\nlooks unique but only leans on the clock being high-resolution enough that two calls land on different values. A JVM's timer is fine, so the flaw was invisible there. Browsers deliberately clamp their clock to about 100 microseconds (a Spectre mitigation), so `nanoTime`\n\nbarely advances between gems and `RNG.nextInt(1000)`\n\ncollides on its own often enough to matter.\n\nNeither runtime was broken. The **code** was, for depending on clock resolution it was never promised. The browser was just honest about it.\n\nThe fix is boring, which is the point:\n\n```\nprivate static long idSeq = 0L;\n\nprivate static synchronized long nextId() {\n    return idSeq++;\n}\n-String id = row + \"-\" + col + \"-\" + System.nanoTime() + \"-\" + RNG.nextInt(1000);\n+String id = row + \"-\" + col + \"-\" + nextId();\n```\n\nA counter is unique on every clock. The guarantee stops depending on the platform.\n\nThe one rule I do not bend: a fix is not done because I say \"fixed.\" It is done when a check that would catch the bug is sitting on disk and passing in CI. For this one, that meant three tests whose whole job is to fail if IDs ever lean on the clock again:\n\n```\n@Test\npublic void idsAreUniqueWithoutRelyingOnClockResolution() {\n    // Mint many gems as fast as possible: a clock-derived id would collide\n    // here on any platform whose timer doesn't advance between calls.\n    Set<String> seen = new HashSet<>();\n    for (int i = 0; i < 50; i++) {\n        GameBoard.Gem[][] b = BoardEngine.createBoard(plain());\n        for (GameBoard.Gem[] row : b)\n            for (GameBoard.Gem g : row) seen.add(g.id);\n    }\n    assertEquals(50 * 64, seen.size());\n}\n```\n\nPlus one for board creation and one for the refill hot path, where new gems get minted every cascade. The suite went from 51 tests to 59. Those three are the receipt that the specific failure cannot come back quietly. Without them, \"I fixed the ID thing\" is just a sentence.\n\nThe commit and the tests are here: [ match3-engine](https://github.com/egnaro9/match3-engine) (\n\n`BoardEngine.java`\n\n, `IdUniquenessTest.java`\n\n).Around the same time, the same \"run it two ways, look for a quiet disagreement\" habit pointed the other direction. Auditing where TeaVM and the JVM diverge, I hit a date case they split on:\n\n```\nYEAR = 2002, WEEK_OF_MONTH = 2   (America/New_York, en_US)\nJVM:   Sun Jan 06 2002\nTeaVM: Sat Jan 12 2002\n```\n\nThis time my code was fine. The bug was in TeaVM's reimplementation of Java's `GregorianCalendar`\n\n. One line used `days - 2`\n\nwhere every neighbouring branch, and the Apache Harmony code it was ported from, used `days - 3`\n\n:\n\n```\n-days += (fields[WEEK_OF_MONTH] - 1) * 7 + mod7(skew + dayOfWeek - (days - 2)) - skew;\n+days += (fields[WEEK_OF_MONTH] - 1) * 7 + mod7(skew + dayOfWeek - (days - 3)) - skew;\n```\n\nOne character. The reason no test had caught it: the suite already had four assertions that reach that exact line, commented out since 2015. My change re-enabled them instead of adding new ones. They fail on the old code and pass on the fix, which is the cleanest proof I could ask for that the fix is real and nothing else moved.\n\nIt [merged into TeaVM](https://github.com/konsoletyper/teavm/pull/1213) on 2026-07-17 and closed a dormant issue. My whole contribution was a `2`\n\nto a `3`\n\nand un-commenting eleven-year-old assertions.\n\nNo cleverer reviewer would have found the first bug by reading the code, because the code looked fine and the tests were green. A second runtime found it by disagreeing. My job was the part a machine can't do: read a \"0 vs 301\" and decide which side was lying, and why.\n\nIf there's a transferable idea here it's just this: don't keep one source of truth for logic you can't fully check by hand. Run it two ways and treat every disagreement as a bug until you've proven which side it lives on. Sometimes it's yours. Once, it was the compiler's.\n\nBoth examples are runnable:\n\n`match3-engine`\n\n`evals-differential-oracle`\n\nThanks to Ryan for the nudge. The last post told you I test things. This one showed you one.", "url": "https://wpnews.pro/news/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end", "canonical_source": "https://dev.to/egnaro9/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end-10cj", "published_at": "2026-07-22 00:01:43+00:00", "updated_at": "2026-07-22 00:29:15.583839+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["TeaVM", "Ryan", "match3-engine"], "alternates": {"html": "https://wpnews.pro/news/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end", "markdown": "https://wpnews.pro/news/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end.md", "text": "https://wpnews.pro/news/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end.txt", "jsonld": "https://wpnews.pro/news/301-duplicate-ids-in-the-browser-0-on-the-jvm-one-real-bug-end-to-end.jsonld"}}