Go to Uphill Solutions' GitHubSubscribe via RSS
Contact Us
Software StrategyRefactoring4 min read

Cutting a 10-Second Load Time to 2, Without a Rewrite


Situation

First site load took 10-plus seconds to first paint.

The custom front end layered on top of the client’s no-code platform had started as a 1,000-line script. A prior agency grew it to 3,000. It kept growing under me. Eventually it became a single 5,700-line JavaScript file, with no one owner ever having designed it as a whole.

Every role, reviewers, admins, subscribers, coaches, downloaded and parsed all of it before seeing anything on screen. Regardless of which piece of the pie they’d actually use.

Complication

A file that size stops being a convenience and starts being a liability.

Past roughly 5,000 lines, editor syntax highlighting itself started failing. The people maintaining the code were reading large stretches of it unhighlighted, plain text, no visual structure to lean on while making changes.

Every change carried the risk of an unrelated regression somewhere else in the file. A single uncaught error would break all custom JavaScript across the entire site. Every user, regardless of role, paid the download and parse cost of code they’d never execute.

And there was no natural place to add new functionality without making the file, and the risk, bigger still.

The obvious fix, a full rewrite, wasn’t available. The platform runs live, for paying users, and can’t go dark for a rearchitecture.

Investigation

Before touching the file, I mapped which code paths each role and each page actually used.

Each page’s views carry their own scripting: form submit handlers, form enhancements, table modifications, conditional show/hide logic. Some of it is shared across every role, a roles/shared/ module holds that, 27 files’ worth. But most of it isn’t. Reviewers run custom review-workflow scripts nobody else touches. A page an admin can’t access has no reason to load its scripts into an admin’s session at all and vice versa.

Those boundaries already existed in the permissions model and the page structure. They just weren’t reflected in the code yet. Splitting along them wasn’t an arbitrary call, it matched how the app already behaved.

Route-based splitting was the other option on the table. It didn’t hold up: some pages are shared across roles, rendering a different view depending on who’s logged in, so the route alone doesn’t tell you which code to load. Role plus page did.

Resolution

I decomposed the monolith into 99 organized modules, split by domain (events/, roles/, lib/, api/, plugins/), in a single coordinated release rather than an incremental drift that risks losing track of what’s been moved where.

Two decisions did most of the work:

  • Role-scoped lazy loading. Each role folder has its own init file that acts as a registry for that role: it registers the role’s event listeners right after login, asynchronously, so nothing blocks initial content from rendering. Some roles pull 30-plus modules; others need only two or three, with a shared core common to all of them.
  • Page-level just-in-time loading. The event listeners load upfront, but the modules themselves don’t. A given page’s actual module only loads when that page renders and its listener fires, not all at once at login. Content shows up as soon as it’s ready instead of waiting on scripts three or four screens away.

Results

  • Initial paint dropped from 10-plus seconds to 2. Role- and page-scoped loading means the browser no longer parses code the current user won’t touch before showing anything.
  • 5,700 lines in one file became 99 organized modules, each scoped to a clear domain, and each back under the line count where editors highlight syntax normally again.
  • Bundle size now scales with role and page, from a shared core up to 30-plus modules for the heaviest roles, instead of every role and every page paying for the whole application upfront.
  • Time to ship dropped. A grep now finds the exact file and function for the task at hand; utilities are grouped by topic and imports point straight to where a function lives, instead of a search through one 5,700-line file.
  • Zero downtime during the transition. The decomposition shipped as a single release against a live platform.
Back to Case Studies

Living with a codebase nobody wants to touch?

Let's break it apart safely, in stages, without a rewrite that stalls feature work for months.

Let's Talk