DEV Community

minia2a
minia2a

Posted on

The `doRegister is not defined` Bug: How a Live-Count Feature Broke Registration

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.


The symptom: a button that did absolutely nothing

A user hit "Get 500 Free Credits" on our registration page and nothing happened. No spinner, no error, no network request. The console had exactly one line:

Uncaught ReferenceError: doRegister is not defined
Enter fullscreen mode Exit fullscreen mode

The button itself was as boring as it gets:

<button onclick="doRegister()">Get 500 Free Credits</button>
Enter fullscreen mode Exit fullscreen mode

And the handler it was calling was a plain top-level function in an inline <script> at the bottom of the page:

async function doRegister() {
  const name = document.getElementById("reg-name").value.trim();
  // ... POST to /api/v1/register-simple, render the result ...
}
Enter fullscreen mode Exit fullscreen mode

An inline onclick resolves the function name against the global scope. A top-level async function doRegister() {} in a classic <script> should land in the global scope. So "not defined" didn't mean the function was missing from the file — it meant the script block that was supposed to define it never actually ran.

The context: a number that kept lying

The page header proudly announced:

<span style="color:#22c55e">154 services available</span>
Enter fullscreen mode Exit fullscreen mode

154 was hardcoded. It had been true once, then it drifted. By the time I looked, the real number was over 1,600 and climbing. Every time the marketplace grew, that "154" got more wrong, and nobody had a process for updating it.

The obvious fix: replace the literal with a live count. I added a small helper that hit our stats endpoint and wrote the number into a couple of spans:

async function loadLiveStats() {
  try {
    const r = await fetch("/api/stats");
    const d = await r.json();
    const s = (id, v) => {
      const e = document.getElementById(id);
      if (e) e.textContent = (v || 0).toLocaleString();
    };
    s("live-svc-reg", d.services);       // header counter
    s("live-svc-browse", d.services);    // "Browse all N services" link
  } catch (e) {}
}
loadLiveStats();
Enter fullscreen mode Exit fullscreen mode

Two targets. The header counter, and the "Browse all N services →" link that appears in the post-registration success message. Then I wired loadLiveStats() to run on page load and called it done.

That's when the button broke.

The subtle bug: the element that didn't exist yet

Here's the part that took me the longest to see.

loadLiveStats() runs on page load. But the #live-svc-browse span it's trying to update doesn't exist on page load. It only enters the DOM after a registration succeeds, because it lives inside the success message that gets injected at runtime:

result.innerHTML =
  "<div class='success-banner'>" +
    "✓ Registered — 500 credits ready" +
    // ...
    "<a href='/services.html'>Browse all <span id='live-svc-browse'>154</span> services →</a>" +
    // ...
  "</div>";
Enter fullscreen mode Exit fullscreen mode

The success message is built as a string and dropped into #reg-result only when the register call returns 200. Before that moment, document.getElementById("live-svc-browse") is null.

So the sequence was:

  1. Page loads.
  2. loadLiveStats() runs, looks for #live-svc-browse, gets null.
  3. Without a null-guard, null.textContent = ... throws a TypeError.
  4. That error, thrown from a helper wired to run at the top level, took the rest of the inline script down with it — the same script block that defined doRegister.

The function never got defined, so the inline onclick="doRegister()" had nothing to call, and the button silently died. The root cause wasn't a typo in the handler name. It was a timing bug: I was asking code that runs at page load to write into an element that is only born after a successful registration.

The hardcoded 154 was the canary. It wasn't just stale — it was the only value that ever showed up, because the live update that was supposed to replace it had been throwing before it could ever work.

The fix

Three small changes, each addressing a layer of the problem:

  1. Null-guard every lookup. The helper already does if (e) — that's the part that stops a missing element from throwing. An element that doesn't exist yet is a normal state, not an error.

  2. Call loadLiveStats() again after the success message is injected. Once the registration response renders and #live-svc-browse actually exists, run the stats update a second time so the "Browse all N services" link shows the real count instead of the fallback:

// after result.innerHTML = ...
loadLiveStats();
Enter fullscreen mode Exit fullscreen mode
  1. Give both counters stable IDs (#live-svc-reg, #live-svc-browse) so the same helper can target them without guessing at structure.

After the fix, the header shows the live count on load, and the "Browse all N services" link inside the success message gets its live count the moment it appears.

What I'd tell past-me

  • "Runs on page load" is a statement about time, not about DOM. If the element you're updating only exists after some async flow finishes, then page-load is the wrong time to look for it. Either guard for its absence, or re-run once it's created.
  • A hardcoded fallback value is a silently decaying bug. 154 looked harmless. It was actually masking the fact that the live path had never worked.
  • onclick="fn()" + "fn is not defined" is usually a dead script block, not a dead function. When an inline handler can't find a top-level function, check whether the script that defines it actually executed — a runtime throw earlier in the same block is enough to kill it.

The fix was ~10 lines. Finding it was the whole story.

Top comments (0)