iAdvizeDocs

Web tag performance

What the web tag costs a storefront page: the size budget we commit to, the current measurement, what we enforce on every change, and how to measure it live on your own storefront.

If you're reviewing the tag before allowing it on a storefront, this page is the answer sheet: what it weighs, how many requests it makes, when it makes them, and what stops it from getting worse. It also says plainly what we have not measured.

The commitments come first because they're the part that stays true. The current measurement comes second, dated, because it moves with every release.

The budget we commit to

WhatCeiling
Loader (loader.js), transferred512 B gzip
Assistant bundle (iadvize-<hash>.js), transferred10 KB gzip
Loader, uncompressed2 KB
Assistant bundle, uncompressed30 KB
Requests before the shopper interacts5 with a widget the shopper sees (4 otherwise, 3 after the first page of a session)
Sub-documents, stylesheets, fonts, images or media before the panel opens0
Layout shift caused by the tag0 for a floating widget, and for an inline button in a mount point you reserve. Starters is the exception (see below)
Event listeners on your document / window9, pinned

These are absolute figures, not "current size plus a margin". Crossing one fails our build: the check runs inside the tag build itself, so there is no build of iAdvize anywhere that ships a tag over budget.

The transferred ceiling has been raised nine times, and the uncompressed one five times

5 KB → 5.5 KB, for several independent chat-bubble widgets on one site. → 6.5 KB, for the question bar, a second loader-rendered surface with its own shadow-root host, DOM builder and per-language placeholder resolution. → 7 KB, for renaming the widget's discriminant field end to end while the bundle sat at exactly its ceiling with no headroom. → 7.5 KB, for starter-question chips on both floating formats (+575 B: the chip renderer, the page-type/language index into the server-pre-selected payload, and the AI disclosure on each chip's accessible name). → 9 KB, for the inline button, the first widget that renders inside your own page rather than against the corner of the viewport, which needed three ways of finding its spot, a bounded watch for a spot that has not rendered yet, and a per-widget visibility signal. A trim came first and returned 119 bytes, and the capability cost the rest; the code review before release then found a selector that could match the tag's own inserted element and freeze the page, and an inline widget that never appeared on a client-side navigation, and fixing those cost another 127 bytes. → 9 KB unchanged, for Starters, a composite inline widget (an icon+label header, resolved starter-question chips, and a free-text field) that reuses the button's mount mechanism and the composer's input/send control rather than adding new ones, measured on that branch alone before it merged with the change below. → 9.5 KB, once Starters merged with a separately-landed change adding URL-pattern targeting to starter questions (globMatch/urlPatternsMatch, a uniform filter/slice, and a gated route-change reconciliation call). Each branch was raised to clear its own measurement in isolation, so their headroom didn't add: the combined bundle exceeded both ceilings again immediately on merge, at 9 343 B. No further trim attempted: both branches were already trimmed on their own, and the increase is two real, wanted features' code. → 10 KB, for the page signal: the tag now reads the page a shopper is standing on (its address without the query string, its title, its description) and sends it with each message, so the assistant can answer "is this in stock?" without the shopper naming the product. A trim came first and returned 35 bytes of the 345 the collection cost, leaving 9 969 B.

The uncompressed ceiling moved for the first time in the release that shipped the button, 24 KB → 26 KB: these two features landed independently and together crossed it while the transferred figure still had room, which is what the ratio between the two makes possible: the bundle compresses about 3:1, so uncompressed growth is the first thing to cross. It moved a second time for Starters, 26 KB → 28 KB: the reused colour and mount-resolution code held the transferred cost nearly flat, but the new format's own markup (a non-interactive header, a reused chip stack, a reused input bar, all wired per-widget) still added real uncompressed bytes gzip folds away better than raw size does. It moved a third time, 28 KB → 28.5 KB, in the same merge that raised the transferred ceiling above, measured at 28 640 B. It moved a fourth time, 28.5 KB → 29 KB, merging in per-question performance measurement (the impression/click signal the launcher and composer chips now emit); that branch's own tag work was independently trimmed to a 574 B raw budget against a baseline carrying neither of the two features above, so stacking it on the already-merged pair consumed headroom the ceiling never allocated for a third feature: measured at 29 243 B, 59 B over the 29 184 B ceiling. The transferred figure (9 579 B) still cleared its own 9 728 B ceiling, so only the uncompressed one moved. It moved a fifth time, 29 KB → 30 KB, with the page signal that raised the transferred ceiling above: measured at 30 581 B, and unlike the four raises before it this one leaves only 139 bytes, so uncompressed is once again the figure the next tag change should expect to hit first. Every raise was a deliberate, reviewed move to a round, publishable figure, never a percentage of whatever the tag currently weighs, and never trimmed by making the shipped loader riskier to fit.

Current measurement

Measured 2026-09-21, from a production build of the tag:

FileTransferred (gzip)Uncompressed
loader.js293 B343 B
iadvize-<hash>.js9 969 B30 581 B
Total script weight≈ 10 KB≈ 30.2 KB

On top of that, the snippet you paste is about 390 bytes of inline HTML, no request of its own.

So a page carries roughly 9.6 KB of transferred script whether or not the shopper ever opens the chat.

The bundle figure is measured against the production app origin. That origin is baked into the bundle as a literal, so a build for a preview environment weighs a few bytes more or less purely because its hostname is a different length; the number we publish, and the number the budget check enforces, is the production one.

The uncompressed ceiling is now the tighter of the two

The assistant bundle is 30 581 B against the 30 720 B (30 KB) uncompressed ceiling: only 139 bytes of slack. The transferred figure has more headroom: 9 969 B against 10 240 B (10 KB), about 271 bytes. The two swapped places with the page signal's release: its code compresses well, so it consumed proportionally more of the uncompressed budget. Anyone about to add tag code should measure both figures, expect to trim first, and treat either ceiling moving as a reviewed change to the table above, never a side effect.

Figures are updated by hand, on purpose

We update the numbers above deliberately, as a reviewed edit, rather than letting a script rewrite them. A number we publish should be one someone chose to publish. The consequence: after a tag release these figures can lag reality by a few bytes, not by the kind of margin the callout above is warning about. The ceiling in the table above is the part to hold us to.

What loads, and when

Your page renders. The snippet is inline: it defines the window.iadvize command queue, stores the site key, and registers a handler on the window load event. Nothing is fetched during your page's initial render. (If the document is already complete when the snippet runs, the loader is injected right away instead.)

On load, the loader is injected as <script async> pointing at /tag/loader.js, 293 B transferred. Being async and post-load, it never blocks parsing, rendering, or your Largest Contentful Paint.

The loader injects the assistant bundle, also async, cross-origin, pinned with an SRI integrity hash. The loader's only job is that injection.

The bundle boots and makes two calls: a GET for the site's display config (branding), and (only on the first page of a tab session) one sendBeacon POST recording that the tag loaded. That's four requests in total on a first page view, three on later pages in the same session.

One more, once a widget is actually seen: a single sendBeacon POST recording which of your widgets became visible. It fires when a widget scrolls into view, once per widget per tab session, and every widget on the page shares one request, so this figure does not grow with the number of widgets you configure. A widget that stays below the fold sends nothing. That is the fifth request in the table above; a page with no visible widget still makes four.

Then nothing. No iframe, no stylesheet, no web font, no image, no media. The conversation iframe (/embed/<key>) is created the first time a shopper actually opens the panel, never before.

Two more network behaviours, so they don't surprise you in a waterfall:

  • Hovering one of your data-iadvize-open triggers adds a <link rel="preconnect"> to the iAdvize app origin. That warms up the TLS connection so the conversation opens faster; it downloads nothing.
  • Batched page telemetry is sent once, when the tab is hidden or the page unloads (visibilitychange / pagehide), never during your page's render.

Caching. The content-hashed bundle is served public, max-age=31536000, immutable, so a repeat visitor pays nothing for it. The loader, whose URL is stable, is public, max-age=300, must-revalidate: a 5-minute revalidation window that bounds how long a bad tag deploy can stick around.

Zero layout shift

Outside a mount point you declare, the tag adds nothing to your document flow. The panel and its optional backdrop are position: fixed, appended to <body>, and they animate with transform and opacity only: compositor-only properties that don't affect layout, so the slide-in cannot move your content. A chat bubble and a question bar are fixed too.

An inline button is the exception, because being in your content flow is what it's for. There the commitment is per mount mode, and it's worth reading before you pick one:

Mount modeAt the mount point
Custom element or marker elementYou can give the element a reserved height, and nothing moves
CSS selectorThe content below the button moves down when it appears, every time

The selector mode inserts the button next to an element we don't own, so there is no box to reserve. On our own fixture the content below an unreserved mount point moved by 72 px, scoring a 0.0325 layout shift. That's a property of the mode, not a defect we're working on. The way to avoid it is to reserve the space, which only the two element modes allow. See Reserve the space.

Two honest limits even on the reservable modes: a custom element the browser hasn't upgraded yet is laid out as display: inline and has no box, so a min-height needs a display beside it; and we can't compute the reserved box for you, because the button's width follows a label that is per-language and rendered in your own font. Zero shift is a claim we make for a fixed geometry you reserve, not for any configuration.

Starters makes no zero-shift claim, in any mount mode

Starters is the one inline format the table above doesn't cover, and deliberately so. A button's box is fixed once configured: reserve the space and nothing moves, in either element mount mode. Starters' height isn't fixed: whether its resolved starter-question chips are present depends on what your library has for that page and language, resolved at runtime from /config rather than known when you configure the widget. So Starters has exactly two possible heights (heading + field, or heading + chips + field), never a claim of zero, in any of the three mount modes, reserved or not. This is a stated limitation of the format, not a defect: see Starters.

Opening and closing the panel moves nothing either way: a button or a Starters widget stays exactly where it is while the panel is open.

Being fixed isn't enough on its own, and one case genuinely touches your page's layout. When the panel locks background scroll (a narrow screen where the panel fills the viewport, or the dimmed backdrop), the tag sets overflow: hidden on <html> and <body>. On Windows and Linux desktop, that removes a classic scrollbar and widens the layout viewport by around 15 px, which moves every horizontally centred element on your page. Here's what the tag does about it:

  • It measures the scrollbar's width before locking and adds exactly that much padding-right to <html>, so the layout viewport width doesn't change. Your original value is restored on close.
  • That padding is added to whatever padding-right your own stylesheet computes, never substituted for it: overwriting your 24 px with 15 px would itself be a layout change.
  • Where the scrollbar takes no space (overlay scrollbars: macOS, mobile), nothing is added. The compensation is inert exactly where the problem can't occur.
  • The lock is applied before the panel and iframe are created, and released only after the 280 ms close animation ends, so our own fixed elements are laid out once, in their final viewport, instead of moving mid-animation.

One case we can't fix from our side

If your page has its own right-anchored position: fixed element (a back-to-top button, a sticky sidebar), the compensating padding on <html> doesn't apply to it: that element still moves by the scrollbar's width while the panel is open. Correcting it would mean editing your stylesheet, so we document it instead of solving it. Default companion mode (desktop, no backdrop) never locks background scroll, so it never arises there.

We measure this on our own fixture: zero shift with no inline widget and with a button in a reserved mount point, and the real measured figure for an unreserved button and for a Starters widget in either resolved state. That isolates what the tag itself adds to layout, at load and across an open/close cycle. It's not a CLS score for your storefront as a whole. For that, launch a real measurement from the site's Performance tab. See Measure it on your own storefront.

Listeners on your page

The tag registers nine listeners on your document and window, and no more:

TargetEvents
documentclick, keydown, pointerover, visibilitychange
windowmessage, resize, orientationchange, popstate, pagehide

Page-wide listeners are the tag's main interaction cost and they're invisible in the DOM, so the set is pinned by a test: adding one fails that test with a printed diff, which makes a new listener a reviewed decision rather than a silent cost.

pointerover is registered as passive, so it can't delay scrolling. resize and orientationchange only do work while the panel is open, and are coalesced to at most one evaluation per animation frame.

What we enforce, and what we only measure

The split is by measurement stability, not by importance.

Enforced (a violation fails a check):

  • both size ceilings (gzip and uncompressed) for both files;
  • the exact set of requests made before any interaction;
  • zero sub-document, stylesheet, font, image or media request before the panel opens;
  • a reference element's position on the host page, unchanged across an open → close cycle with the scroll lock engaged;
  • zero raw layout-shift entries, on load and across an open → close cycle, and with a button mounted into a reserved mount point (Starters is reported, not gated, on this check; see Zero layout shift);
  • the pinned listener inventory;
  • a sensitivity check that applies an uncompensated scroll lock and requires that a shift is detected, so the checks above can't quietly lose the ability to fail.

Measured and reported, never gated: script evaluation time, long tasks, the browser's own per-request byte figures, the shift an unreserved button mount point produces, and the shift a Starters widget produces in either resolved state (chip stack present or absent). Those are reported rather than asserted at a figure on purpose: an unreserved button's shift scales with your own type scale, and Starters' with whatever your starter-question library resolves. A threshold on either would be a threshold on your page or your content, not on the tag. What is asserted for both is that the transition between states is real and detectable. Otherwise a zero-shift claim elsewhere on this page would mean nothing.

That last split is deliberate and worth stating rather than glossing over. CI machines share CPU with no isolation, so any millisecond threshold flakes; a flaky gate gets loosened or skipped, and you're left with a green check that means nothing, worse than no check. So timing is a trend we record and read, not a guarantee we make.

On the layout-shift checks, one detail matters: they read raw layout-shift entries and deliberately ignore the hadRecentInput flag. Cumulative Layout Shift excludes anything within 500 ms of a user input, and our panel opens from a click, so a CLS-based check would score 0 even with the scroll-lock shift above still present, and would certify the tag as safe while it wasn't. That's why the check asserts the stronger property: the tag shifts nothing, ever, whether or not a click preceded it.

Where the checks run. The size budget is part of the tag build, so it runs in every build. The browser measurements run as their own job on every pull request, with the footprint report kept as an artifact. That browser job reports today; it isn't yet a hard block on merging.

These figures come from a fixture, not from your storefront

Stated plainly, because it changes how you should read everything above.

Every measurement on this page is taken on a controlled test page we host in our own repository: a centred content container, a hero image with explicit dimensions, no web font, all assets inlined, and the same install snippet the dashboard gives you, driven in Chromium at a desktop (1280×720) and a mobile (390×844) viewport.

That makes it a floor test: it proves the tag cannot get worse than a known baseline without a check going red. It is not a before/after comparison on a real storefront, not field data from real shoppers, and not a Core Web Vitals score. The test page also uses a placeholder site key, so the conversation iframe stays inert: the measurement covers what the tag costs your page, not what the conversation UI costs once a shopper opens it.

Measure it on your own storefront

Everything above comes from our own fixture. To see what the tag costs your storefront (and how the page performs for real shoppers), open the site's Settings → Performance tab.

Launch a run

The URL field is pre-filled from the site's own registered URL, editable per run. Measure now checks that URL's origin against the site's own registered origin allowlist (Settings → Public access), rejected outright if it isn't listed, no exception, and re-checks it again when the background job actually runs, in case the allowlist changed in the meantime.

A launched run moves pending → running → completed (or failed, if something broke outside the comparison itself: a Google API error, an unreachable URL). The page refreshes on its own while a run is in flight. The tab is split in three: Most recent run (the lab delta below), Real-world field data (CrUX), and History: the site's full run history, one row per run, with a Score (off → on) column whose value you can hover for the full per-metric breakdown. Launching a new run never jumps you back to Most recent run if you were looking at Field data or History.

The causal lab delta

Once a run completes, you get a same-instant, same-page comparison, driven by Google PageSpeed Insights: the storefront URL measured once with the tag running, and once with the tag's own kill switch (?iadvize_disable=1) turned on: the same page, the tag itself the only difference between the two. Each side is measured 5 times and reduced to its median, to smooth out normal PSI run-to-run noise; the 5 raw runs per side stay visible behind a "based on N tag-on and N tag-off runs" toggle rather than disappearing into the median.

The delta covers Performance score, LCP (largest contentful paint), CLS, TBT (total blocking time), and FCP (first contentful paint): tag-off, tag-on, and the difference, side by side.

A run measured before this page's LCP addition has no LCP value

LCP is parsed from the same PageSpeed Insights response the other four metrics already come from, so adding it costs no extra API call, but it only exists for runs measured after this addition shipped. An older run in your history simply shows LCP as not measured for that run, not as zero or an error.

A run can come back not comparable, in which case no delta is shown and you get a message explaining why, instead of a number to second-guess:

  • Google's own measurement came back incomplete. PSI/Lighthouse occasionally fails to resolve a metric like LCP on an otherwise ordinary page, a known Lighthouse limitation, not something about your storefront specifically. One retry absorbs this before the run gives up on that side.
  • The tag-on/tag-off pairing itself looks broken: the tag-on side shows no evidence of the tag's own requests, the tag-off side still shows them (the kill switch didn't suppress it), the two sides returned different HTTP statuses, or their total page weight differs by more than about 10%. Any of these means the two runs weren't measuring equivalent pages, so the comparison is discarded rather than published.

Real-world field data (CrUX)

Separately from the lab delta (its own tab, shown even when the lab delta wasn't comparable), the same run also pulls the storefront origin's history from Google's Chrome UX Report (CrUX): real Chrome users' own LCP, CLS, INP, and TTFB, reported as 75th-percentile ("p75") weekly figures (a percentile, not an average, and not isolated to the tag's effect): this is your whole page's real-world experience with the tag included, not a delta. The most recent week's four figures are highlighted first, followed by a table of every available week, most recent first. If your storefront doesn't have enough real Chrome traffic yet for Google to report on, you'll see "No field data available for this URL yet" instead of a guess. The field-data half of a run is best-effort and never fails the run itself.

Mobile only, for now

Both the lab delta and the field data are measured for the mobile form factor. Core Web Vitals are conventionally reported mobile-first, and there's no desktop option in the Performance tab yet: a scope decision for this first version, not an oversight.

Reproduce it on your own page

Install the snippet on a test page. See Embed the assistant. A locally served page works.

Open DevTools → Network, filter on iadvize, and hard-reload. You should see four requests: loader.js, iadvize-<hash>.js, the config call, and the beacon. Scroll a widget into view and a fifth appears: one beacon carrying every widget that became visible, not one per widget. No iframe request appears until you click your own trigger.

Transferred sizes in that column may not match ours to the byte: our figures are gzip computed at build time, while your browser gets whatever content encoding the CDN negotiates with it (Brotli is typically smaller than gzip).

For layout shift, log raw entries before the tag boots, then open and close the panel:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.value, entry.hadRecentInput, entry.sources)
  }
}).observe({ type: 'layout-shift', buffered: true })

Don't filter on hadRecentInput. The panel opens from a click, so a genuine shift carries that flag and the usual CLS recipe would hide it.

To exercise the scroll-lock path on a desktop browser, turn the backdrop on first:

iadvize('set', { backdrop: true })

Without it, a desktop panel is a companion that never locks background scroll: nothing reflows, and you'd be watching a case that can't move.

On this page