An infinite loading spinner looks simple from the outside, but underneath was a small URL-handling assumption in an ES module loader—and a malformed request that broke the entire builder.
01The symptom: trapped behind an infinite loading screen
An infinite loading spinner is one of those issues that looks deceptively simple from the outside, but can hide a surprisingly deep architectural problem underneath.
Recently, I investigated a WPForms Lite issue where the form builder interface refused to open. After navigating to WPForms → Add New, the page presented a blank canvas covered by an endless loading animation.
WPForms Lite
Reported in Plugin Support
“I’m trying to add my first form with WPForms lite, and the page to create the form will not load, it just sits there with the loading symbol going around and around.
I’ve tried disabling the other plugins with no luck. The developer console returns 404 errors for a few different js files.”
The initial troubleshooting steps were standard:
- Deactivate and reinstall the plugin to rule out corrupted asset files.
- Deactivate all other plugins and switch themes to isolate conflicts.
- Check whether hosting security rules, mod_security, or cloud firewalls were blocking requests.
None of these worked. Reinstalling a plugin only helps if the files on disk are damaged or altered. When the application logic itself generates invalid requests, reinstalling simply reinstalls the exact same behavior. To find the real answer, I had to look past the UI and trace what the browser was actually executing.
02Step 1: Inspecting the browser console
Opening DevTools (F12) immediately shifted the direction of the investigation. The Console tab was logging unhandled promise rejections and 404 Not Found errors for JavaScript module files required during builder startup.
When an interface fails to load, don’t ask “Why is the builder stuck?”. Ask: “Which request is failing, and why is the browser requesting that specific URL?”
The builder relied on ES modules to bootstrap its controls and UI components. If any core module failed to download, the initialization chain broke before rendering could begin. The problem wasn’t a broken PHP execution or a database deadlock—it was a client-side module resolution failure.
03Step 2: Following the network request
Inspecting the Network tab revealed that the failing JavaScript module requests were being dispatched with an unexpected, malformed path.
// 1. Expected asset path:
/wp-content/plugins/wpforms-lite/assets/js/admin/builder/applicators.min.js
// 2. Actual malformed HTTP request generated by the browser:
https://site.test/wp-admin/builder/modules//wp-content/plugins/wpforms-lite/assets/js/admin/builder/applicators.min.js
./modules/ prefix prepended directly before the root /wp-content/ directory.The expected URL was a clean asset path pointing into /wp-content/plugins/wpforms-lite/assets/.... Yet somewhere along the execution chain, the browser was resolving:
./modules//wp-content/plugins/...
Relative to the current admin URL (/wp-admin/admin.php?page=wpforms-builder...), this resolved into .../wp-admin/builder/modules//wp-content/..., which naturally triggered a 404 Not Found response from the web server.
The central question became: Why was ./modules/ being prepended to a path that already pointed directly to the plugin’s assets?
04Step 3: Following the JavaScript module loader
Searching through the builder’s minified source code (admin-builder.min.js) revealed the dynamic module-import routine responsible for loading feature bundles:
e = e.startsWith( wpforms_builder.content_url ) || /^https?:\/\//.test( e )
? e
: "./modules/" + e;
return import( e + "?v=" + wpforms_builder.version );
content_url string or an explicit HTTP/S protocol, the loader assumes it must be an internal relative module name.Two checks determine how a module path is treated:
e.startsWith( wpforms_builder.content_url ): Checks if the path begins with the site’s full content directory URL./^https?:\/\//.test( e ): A regular expression verifying if the path begins with an absolute protocol (http://orhttps://).
If neither check succeeds, the loader assumes the string is merely a local module filename (such as choices.min.js) and prepends ./modules/ before passing it to native import().
05The edge case: root-relative URLs
Consider what happens when an environment or optimization plugin serves assets using root-relative URLs:
/wp-content/plugins/wpforms-lite/assets/js/admin/builder/applicators.min.js
This is a completely valid URL format across the modern web. It starts with a forward slash (/), instructing the browser to resolve from the domain root. However:
- It does not begin with
https://orhttp://→/^https?:\/\//.test(e)evaluates tofalse. - If
wpforms_builder.content_urlcontains the full domain (e.g.,https://site.test/wp-content),e.startsWith(...)also evaluates tofalse.
| Module Input URL | Type | Regex Test | Loader Behavior | Network Result |
|---|---|---|---|---|
https://site.test/wp-content/... |
Absolute URL | true | Loads as-is | 200 OK |
/wp-content/plugins/... |
Root-Relative | false | Prepends ./modules/ |
404 Not Found |
//cdn.site.test/assets/... |
Protocol-Relative | false | Prepends ./modules/ |
404 Not Found |
utils.min.js |
Local filename | false | Prepends ./modules/ |
200 OK |
Because both tests returned false, the loader treated the root-relative path as a relative file, prepending ./modules/. This concatenated string created the broken request observed in the network trace.
06Why one 404 broke the entire builder
In traditional WordPress admin scripts, a failed <script> tag produces a console error, but the rest of the inline HTML and UI still renders. Why did this single module 404 freeze the whole interface?
The builder loads its dependencies using dynamic ES module imports inside a Promise.all() collection:
/wp-content/plugins/wpforms-lite/assets/.../applicators.min.js
Root-Relative Path
Fails to match protocol → Loader assumes path is a local file
Evaluates to: false
.../builder/modules//wp-content/...404 Not Found • Import Rejects
Promise.all() aborts immediately. Cleanup hook never runs.
#wpforms-builder-overlay Stays Frozen
import() encounters a 404, the promise rejects immediately. Because the loader lacked error recovery, the initialization routine halted, leaving the DOM overlay indefinitely visible.By default, Promise.all() rejects immediately if any single promise rejects. When the root-relative module returned a 404, the promise rejected. As a result:
- The completion callback that removes
#wpforms-builder-overlaywas never invoked. - The builder DOM controls were never instantiated.
- No visible user-facing error message was rendered on the screen.
To the end-user, the software simply looked frozen in time: a spinning wheel with no hint of what went wrong.
07The fix: normalizing module URLs with a WordPress filter
Because the issue stems from root-relative paths failing the regex check, the solution is straightforward: normalize all module URLs to full absolute URLs before they reach the builder script.
WPForms Lite provides the wpforms_builder_js_modules filter hook, which passes the array of module URLs registered for the builder. By prefixing root-relative paths with site_url(), we ensure they pass /^https?:\/\//.test() without altering local internal filenames.
add_filter( 'wpforms_builder_js_modules', function( $modules ) {
$site_url = untrailingslashit( site_url() );
foreach ( $modules as $key => $url ) {
// Target root-relative URLs (/...) while preserving protocol-relative (//...)
if ( str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) ) {
$modules[ $key ] = $site_url . $url;
}
}
return $modules;
}, 99 );
functions.php or an mu-plugin ensures all module URLs are fully qualified absolute URLs.The condition str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) is essential:
- It identifies true root-relative URLs that begin with a single slash (
/wp-content/...). - It explicitly excludes protocol-relative URLs (
//cdn.example.com/...) to prevent accidental double prefixes.
08Verification: restoring the builder
After placing the snippet in an active functions.php (or a must-use plugin) and performing a hard browser cache refresh, the change was instantaneous.
↓ filter applied ↓
Checking the Network tab confirmed that all previously failing requests now returned clean 200 OK responses. The builder initialization script concluded, the overlay was dismissed, and the drag-and-drop form editor became completely responsive.
09Lessons for engineering and debugging
This investigation served as a valuable case study in web architecture, defensive coding, and JavaScript module loaders:
1. Don’t assume every web URL is absolute
Modern WordPress environments are diverse. Between headless architectures, reverse proxies, staging clones, and root-relative optimization plugins, asset URLs do not always conform to https://example.com/asset.js. Code that parses or loads URLs must account for:
- Full absolute URLs (
https://...) - Root-relative URLs (
/...) - Protocol-relative URLs (
//...) - Path-relative identifiers (
./...or bare specifiers)
2. A 404 does not always mean the file is missing on the server
When engineers or support teams see a 404 Not Found, the immediate instinct is often to check file permissions, reinstall the plugin, or verify server paths. However, if the server returns 404 because the client requested an invalid URL, no amount of file checking will reveal the flaw. Always verify what URL was requested before checking where the file lives.
3. Dynamic imports require resilient error boundaries
Dynamic import() is a powerful modern web feature, but binding multiple imports into an unhandled Promise.all() creates a fragile all-or-nothing failure state. A robust client-side architecture should:
- Catch module import rejections individually with
.catch(). - Surface a clear diagnostic error notice to the user (e.g., “Failed to load module X. Check network connectivity or firewall rules.”).
- Provide an inline retry button rather than leaving a permanent loading overlay.
10The technical support engineering workflow
This case study illustrates the exact mindset that distinguishes reactive troubleshooting from high-impact technical support engineering:
- User Symptom: The client observes an ambiguous symptom: “The builder won’t load.”
- Reproduce & Observe: Replicate the issue in an isolated environment under identical conditions.
- Console & Network Inspection: Bypass the visual UI and inspect the network layer for failing HTTP transactions.
- Identify the Anomaly: Spot the malformed URL pattern (
./modules//wp-content/...) in failed requests. - Trace Back to Code: Inspect the dynamic loader logic in
admin-builder.min.jsto locate the regex test. - Isolate the Edge Case: Determine why root-relative paths fail the
/^https?:\/\//check. - Apply Targeted Fix: Intercept and normalize the URLs via WordPress filter (
wpforms_builder_js_modules). - Verify Resolution: Confirm HTTP 200 responses, error-free console logs, and restored builder functionality.
Transforming the Conversation: Following this workflow transforms a vague report (“WPForms is broken on my site”) into an actionable, precise engineering diagnosis: “The builder’s ES module loader regex rejects root-relative asset URLs, creating malformed paths that abort Promise.all().”
11Conclusion: asking the right question
The issue looked like a generic WordPress loading hang. It wasn’t fixed by clearing caches, deactivating plugins, or reinstalling software.
The root problem was a single subtle URL-handling assumption in JavaScript—one that worked fine on typical setups, but broke down on environments utilizing root-relative asset paths.
In technical support and engineering, the most critical skill isn’t having every solution memorized. It’s knowing what questions to ask when things go wrong:
Don’t just ask, “What can I try next?”
Ask: “What is the system actually doing right now?”
That is where real problem-solving begins.
