---
title: "A 47,490-line JavaScript file and what it tells you about a profitable legacy app"
canonical: https://dxdev.com/blog/47000-line-js-file-profitable-legacy-app/
datePublished: 2026-02-27
---
The file I edited three times in one day was 47,490 lines long. One file. The client-side brain of the tournament admin in my sports SaaS, and the only way to find a function in it is by line number. The cross-account link fix I shipped that afternoon lives around line 40877. This app pays my bills, and the line count is a symptom of something more useful than neglect.

The day was a single-feature grind on tournament event folders. An org admin can now drill into a child event from a dropdown, and every commit I pushed between 11:16 and 16:51 was the same underlying bug wearing a different costume. Five commits on the feature branch. The largest live-code change was +319/-104 across 13 files, and even that was mostly additive: new methods, new branches, almost no rewriting of existing functions. That pattern is the whole point.

## Why the file is that big, and why it stays that big

The client file is 47,490 lines because there is no build step. The server-side handler next to it is 12,295 lines for the same reason. This is Classic ASP running server-side JScript on IIS, a codebase that predates ES modules by years. There is no bundler, no module system, no import graph. The closest thing to an architecture is concatenation. You add a feature by adding lines to the file that already exists, and the file that already exists has been accreting features customers paid for across roughly fifteen years.

Splitting it has real risk and zero new-customer value. Nobody has ever churned over a large source file. The blast radius of breaking the bundling on a 47k-line file that drives the highest-value admin screen in the product is enormous, and the upside is that my editor scrolls faster. That trade never clears the bar, so it never happens. Fifteen years of that decision being correct is how you get to 47,490 lines.

This is what a successful small-team product looks like at this age. The mess is the residue of shipping things people bought instead of refactors nobody asked for. Before you write the rewrite proposal, notice that the monster file is generating the revenue that pays for your time to complain about it.

## The real hazard is not the line count

Here's the part people get wrong. The line count is not the bug factory. The shared scope is. When everything lives in one enormous file reading from a handful of ambient globals, two variables that used to be equal can stop being equal, and suddenly every call site that trusted their equality is a latent bug.

That is exactly what happened. The app has two ambient globals every page reads: `sessionAccount` (the logged-in account, the one in the URL) and `activeAccount` (the account whose data you're actually operating on). For fifteen years those were always the same object, so the code used them interchangeably, hundreds of times. Then event folders broke the equivalence. An org admin picks a child event via a query parameter, a helper called `switchActiveAccount()` reassigns the module-level `activeAccount` to point at the child mid-request, and `sessionAccount` stays the parent. Now `sessionAccount.username !== activeAccount.username` for the first time ever.

Nothing threw. That's the insidious part. Every query that did `params["username"] = sessionAccount.username` kept happily targeting the parent org, the save succeeded against the wrong row, and you only noticed when data showed up under the wrong event or never showed up where you were looking. The save-bug-fix commit was a near-mechanical sweep replacing `sessionAccount.username` with `activeAccount.username`, about 80 such lines in the main tournament handler alone, plus INSERTs that were writing parent/master/group fields to the wrong account.

You cannot grep your way to confidence on that. Grep finds every use of `sessionAccount.username`. It does not tell you which of those uses runs after `switchActiveAccount()` has already moved `activeAccount` to a different account. The call site looks identical either way. The only way to know is to trace the execution path and reason about which globals have been mutated by the time each line runs. In a 47,000-line file with no module boundaries, that is a manual exercise every time. A mutable global that 100 call sites read is fine right up until the day you introduce impersonation, sub-accounts, or any context switch. Then every one of those reads is a question you have to answer by hand.

## How you actually work inside it: additive branches and explicit overrides

The transferable skill is making safe, surgical, additive changes, and the day was a clinic in that.

The account-scoping helper, `buildQueryFilter(pref, div, teamsExclude, options)`, defaults internally to `activeAccount: sessionAccount`, which is "scope to the logged-in account unless told otherwise." That default was correct for fifteen years and wrong the instant an org admin could be editing a child event. The wrong fix is to flip the default, because that breaks the silent majority of non-tournament callers who genuinely want `sessionAccount`. The right fix is to make the new path explicit at every call site:

```
buildQueryFilter('','','',{activeAccount:activeAccount})
```

That exact override token shows up about 27 times across the day's diffs, in add-folder, add-competition, add-bracket, delete-pool, and a dozen more. Four positional args, three of them empty, just to reach the options bag. The call-site noise is itself a design critique: a signature you reach into with `('','','',{...})` should have been `(options)` from the start. But the tedium is the interest you pay on an old default, and paying it beats the alternative. Because `options` is `$.extend`-ed over the defaults, every override is clean and local. No global toggle, no hidden mode flag, no new state for the next person to trip over.

When you migrate a context-aware helper to a new context, the safe move is explicit overrides at the call sites, even when it's ugly, not a quiet change to the default that the silent majority depended on.

The same instinct shows up in how the page resolves which event you're looking at. Hitting the bracket manager with no event selected doesn't do one thing, it branches: zero events redirects you to go create one, one event auto-selects it, two or more with none chosen drops into a "select event" mode that hand-builds a minimal empty structure and skips the expensive DB load entirely. Three explicit branches, each added next to the old logic rather than on top of it. The old access guard got commented out, not deleted, which is its own legacy tell. In a codebase with no test suite, commented-out prior attempts are an inline changelog. A later commit finally deleted three of those graveyards, including a misspelled internal state reference sitting in a comment, the kind of dead code that silently no-ops if a hurried future me ever uncomments it.

## Related

- [The Ambient-Global Bug: When "Current Account" Is a Mutable Global Your Whole App Reads](the-ambient-global-bug-mutable-current-account): the specific bug class this file is most prone to
- [Hand-rolled hydration: passing server state to the browser with no framework](globalvars-injection-server-state-to-client-no-framework): how server-side globals get bridged to the client in the same codebase
- [A fluent DOM builder for legacy ASP: modernize without a rewrite](fluent-dom-builder-modernize-legacy-asp-no-rewrite): additive modernization pattern that works inside a monster file
- [The Duplicated-Derivation Bug: One Fragile Expression in Four Queries](duplicated-derivation-is-a-correctness-hazard): when copy-paste across a monster file produces correctness divergence
- [A Client Tree-Walk That Was Really a Server Business Rule](client-tree-walk-to-server-business-rule-delete-gate): business logic hidden inside the same 47k-line client file
- [A var EDIT_DATABASE = false Sat in a Branch for 2.5 Years. Then We Shipped It.](edit-database-false-debug-flag-shipped-after-years): how long-lived branches in a legacy app quietly accumulate risk
