Skip to content

Instantly share code, notes, and snippets.

@diegoeche
Created April 14, 2026 22:07
Show Gist options
  • Select an option

  • Save diegoeche/bd2baede2f9dc5690e78c69a5ae29326 to your computer and use it in GitHub Desktop.

Select an option

Save diegoeche/bd2baede2f9dc5690e78c69a5ae29326 to your computer and use it in GitHub Desktop.
widget.js: Why Widgets Are Slow

widget.js: Why Widgets Are Slow

The Madness

widget.js is a 6,163-line, 260KB single file that every Stamped merchant loads on every page of their store.

It sometimes loads twice. We've observed this in the wild but the exact cause isn't fully diagnosed. There are at least two mechanisms that can inject it: the Shopify ScriptTag API (registered programmatically) and a manual <script> tag in theme.liquid. The code tries to detect duplicates before creating a new ScriptTag, but it never removes the other copy. There are runtime guards to prevent double-initialization, but by then the browser has already downloaded and parsed the full 107KB a second time. The guard is like locking the front door after the elephant is already in the living room.

It bundles jQuery 1.11 (2014 vintage). On load, it checks if the store already has jQuery. If not, it fetches it from Google's CDN as a blocking network request before anything else can happen. If the store does have jQuery, it hijacks that copy. Either way: a 12-year-old library is the gatekeeper to showing a single star rating.

It bundles LazyLoad, TimeAgo, and Slick Carousel — all inlined as minified blobs. Every store pays the parse cost for all three even if they only show review badges.

The initialization is a chain of blocking steps:

  1. Parse 260KB of JS
  2. Scan the DOM for the <script> tag to extract apiKey using .indexOf() on the src
  3. If no API key found, fire a blocking XHR to stamped.io/api/getappkey
  4. Check if jQuery exists — if not, fetch it from Google CDN
  5. Wait for jQuery's document.ready — but if it hasn't fired within 3 seconds, force-start anyway ('Library not started, fallback')
  6. Once started, inject Google Fonts CSS into <head> (render-blocking)
  7. Initialize everything at once: UGC widgets, reviews, and the full rewards/loyalty system
  8. The rewards path fires another API call to check if the store has Loyalty 2.0, then another to load the launcher, then builds two iframes (one for the launcher button, one for the rewards window) — each with their own CSS, their own copy of FontAwesome loaded from kit.fontawesome.com, their own Google Fonts link, and a hardcoded 1-second setTimeout before the rewards window is considered "loaded"

So on a store that uses loyalty: parse 260KB, wait up to 3s for ready, fetch jQuery, fetch the API key, check the loyalty feature flag, fetch the launcher HTML, wait 1 second, build two iframes, load FontAwesome twice, load Google Fonts twice. That's the critical path.

It has an IE compatibility layer. CustomEvent polyfill, document.attachEvent fallback, $.support.cors = true for IE's broken CORS. A mobile detection regex that tests for BlackBerry, Palm OS, and Windows CE. Somewhere, a Palm Pilot is flattered.

It monkey-patches String.prototype globally on every merchant's store.

It's "minified" by the ASP.NET bundler — basic whitespace removal from the .NET Framework era. Modern minifiers like terser (the industry standard — it's what webpack, Vite, and Next.js all use under the hood) go much further: they shorten variable names, eliminate dead code paths, collapse constants, and restructure expressions. Running terser on the same source with zero code changes would produce a meaningfully smaller file.

It's deployed by hand — edit, run the bundler, upload to S3 via the AWS console, manually invalidate CloudFront. The repo's own README warns the S3 file may not match what's in git. We have two sources of truth, which is another way of saying we have zero.

No versioning. One URL for every merchant, every version, forever.

Why It's ~3s

The script is parser-blocking — the browser can't render anything below the <script> tag until the entire 107KB is downloaded, parsed, and executed. Then it kicks off the init chain: potentially two more network round-trips (getAppKey + jQuery) before it even starts asking for widget data.

What We Could Do

We don't need a rewrite. We could start ultra-conservatively — pick one thing, ship a widget.v2.js alongside the current file, and let merchants try it:

  • Just remove jQuery. The 71 calls are all replaceable with fetch, URLSearchParams, querySelector, innerHTML. That alone cuts a blocking network round-trip and removes the dependency on a 2014 library.

  • Or even smaller: just add a URL override so we can point any single store at widget.v2.js for testing, without touching everyone else.

  • Or just: run the current source through terser and upload that instead of the ASP.NET bundler output.

Any one of these is a day of work, zero risk to existing merchants, and we can roll it out on a store-by-store basis to measure before/after. We can even test locally before touching anything — a browser extension like Resource Override or Requestly can intercept the CDN request and swap in our modified file on any live store, so we see exactly what changes before anyone else does.

@ThiagoDallacqua

Copy link
Copy Markdown

Nice overview! Here's my suggestions

We could build, on top of widget.v2.js a better bundling strategy too, splitting vendor bundle from the other chunks, and using a long term cache strategy for vendor bundles, and use "cache first -> then revalidate" strategy for bursting the stale cache when necessary, this way, even after removing jQuery as dependency (which I agree that we could do if we're not supporting really old/legacy browsers), we would improve rendering time (specially first contentful paint score). Most modern bundlers support creating such strategies by default on via config property.

The entry chunk should be really small and have only the necessary to spin up what's needed at that time, like reading the API key, detecting what's needed on the current page and orchestrating everything else via lazy loading and/or dynamic imports, once that parsing is done, we can use pre-fetching for the rest, if needed. All of this can be done via fetch API, which doesn't block client interaction without using async/await

Utility libs can also be splitted on a separate chunk and lazy loaded on demand, which will reduce even more of our initial bundle size, and can be cached with less aggressive strategies as well, which will have a "single cost" per client visit.

From this point on, we can leverage the bundler tool for versioning the build assets, which will generate other positive side-effects:

  • better cache management (cache will be considered stale when bundle hash changes, reducing/removing client-side cache issues)
  • improve performance scores with a better waterfall dependency linking/fetching, freeing client-side interaction while the widget resolves its dependencies and loads/renders only what's necessary at that point.
  • dead code removal, most bundlers nowadays have tree-shaking feature, which automatically detects dead code while compiling/bundling the build assets, which will reduce bundle weight even further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment