The duplicate function that never threw
My chat frontend is one hand-written app.js loaded as a module:
<script type="module" src="/static/app.js?v=34"></script>
Yesterday I added a streaming upload with a progress bar, and with it a byte formatter:
function fmtBytes(b) {
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return (i === 0 ? b : b.toFixed(b < 10 ? 1 : 0)) + ' ' + u[i];
}
600 lines above it, written months earlier and long forgotten, was another one:
function fmtBytes(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + ' MB';
if (n >= 1_000) return (n / 1_000).toFixed(1) + ' KB';
return n + ' B';
}
In a plain script, that's fine. Function declarations hoist, the second one wins, nobody notices. In a module, top-level function declarations are lexically scoped, and duplicate lexical names are an early error — the same class of error as a stray brace. The file never parses, so not one line of it ever executes.
Why it looked fine
Here's the part that cost me the time: the app still rendered. The sidebar, the composer, the "ready" splash — all of it is static markup in index.html. What was missing was everything the script does: the conversation list was empty, no click handler fired, no typeahead appeared. It read as "backend's being slow", not "the frontend is entirely dead."
And an early error never reaches a window.onerror handler or any logging you install inside that module, because the module didn't run to install it. Server logs are clean. HTTP is 200. The file is on disk, correct, and complete.
The one-line diagnosis
I was driving a real browser over an HTTP API, so I could evaluate in the page's context. The trick is to import the module again and catch the rejection:
import("/static/app.js?probe=1")
.then(() => "module ok")
.catch(e => "ERR: " + e.message)
// → "ERR: Identifier 'fmtBytes' has already been declared"
The ?probe=1 matters — a dynamic import() of the exact same URL returns the cached module record and resolves happily. Change the query string and you get a fresh fetch, a fresh parse, and the real error object in a promise you can actually read.
The instinct I had to unlearn first: probing for the symbol.
typeof renderSlashMenu // "undefined" — proves nothing!
Module top-level names are never globals. undefined is the correct answer whether the module loaded perfectly or never parsed at all. I burned two round trips on that before switching to the import probe.
Two guards, both cheap
Delete the stale copy, and then stop it recurring. First, syntax-check the file as a module — this is the bit that bites, because the obvious command silently checks the wrong grammar:
node --check app.js # parses as a SCRIPT — duplicate is legal, passes
cp app.js /tmp/app.mjs && node --check /tmp/app.mjs # parses as a MODULE — fails
node --check picks its grammar from the file extension, not from how your <script> tag loads it. A .js file is a script to Node and a module to the browser, and that gap is exactly where this bug lives.
Second: bump the cache-busting query when the file changes. ?v=34 served a stale-but-parseable copy to some clients and the new broken one to others, which made the symptom look intermittent and browser-specific while I was still guessing.
The real lesson isn't about fmtBytes. It's that "the page renders" and "the JavaScript ran" are completely independent facts, and I'd been treating the first as evidence of the second for years.