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), transferred18.5 KB gzip
Loader, uncompressed2 KB
Assistant bundle, uncompressed55 KB
Requests before the shopper interacts6 with a widget the shopper sees (5 otherwise, 4 after the first page of a session), plus one the first time in a tab session the Panel reduced state button is seen
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, and one more passive scroll listener when a question bar renders

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 eighteen times, and the uncompressed one twenty 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. → 10.5 KB, for the loader writing the conversation's join key onto your Shopify cart, so an order a shopper builds themselves can be linked to the conversation (raise 11 in the build script's own count; raise 10, for the loader resolving the shopper's language from the page, moved only the uncompressed ceiling). → 14 KB, for the question bar's engaged states, then → 14.5 KB, for the question bar's width following its size, which also moved the uncompressed ceiling from 43 KB to 44 KB. → 15 KB, for styling each part of a widget on its own (its starter questions, its borders, the Starters title and field), which also moved the uncompressed ceiling from 44 KB to 45 KB. → 16.5 KB, for the Panel reduced state button, which also moved the uncompressed ceiling from 45 KB to 49 KB. → 17 KB, when that button and the Starters card's text sizes and layout, each within its own ceiling, were merged: uncompressed 49 KB to 51 KB. All four are described below.

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. The twelfth raise, 31 KB → 32 KB uncompressed, came with the page-type lookup: the loader now asks which page type your site's pages hold for the current path, and uses it for widget targeting when you declare none (see When you declare nothing). Measured at the production origin: 10 353 B transferred / 31 732 B uncompressed before, 10 569 B / 32 397 B after, once a trim of one function shared by the set command and the late page-type answer had given back 7 B and 32 B. The transferred ceiling did not move: it stays at 10.5 KB, with 183 B to spare. Raw crossed 31 KB, so it went to the next publishable figure, 32 KB (32 768 B, 371 B of headroom). The thirteenth raise, 32 KB → 33 KB uncompressed, came with the send button of the question bar and of the Starters field: it used to draw a text ↑ in your page's own font, so its shape and centring changed from one site to the next. It now draws the same arrow-up icon as the send button inside the panel, as an inline SVG. Measured at the production origin: 10 625 B transferred / 32 485 B uncompressed before, 32 830 B uncompressed after, then 10 739 B / 32 816 B once a trim had given back 14 B. The trim did not touch how the arrow is drawn: its rounded caps and joins, and the attribute that hides it from screen readers, stay. The transferred ceiling did not move: it stays at 10.5 KB, with 13 B to spare. Raw went to the next publishable figure, 33 KB (33 792 B, 976 B of headroom).

The most recent raise moved only the uncompressed ceiling, 54.5 KB → 55 KB (56 320 B), for an inline widget placed with a CSS selector showing twice. With Just before it or Just after it, the widget sits next to its target, not inside it, so when your theme re-rendered that one element the widget outlived it, and the fresh element got a widget of its own. The tag now remembers which element each widget was placed against. When the new element gets its widget, the tag removes, in the same step, a widget whose element has left the page, so when a replacement arrives, the page never shows two widgets or none. A widget whose element leaves the page for good stays where it was, as before. Measured at the production origin: 18 483 B transferred and 55 782 B uncompressed before, 18 572 B and 56 009 B with the change (+89 B and +227 B), against 26 B of uncompressed headroom. The transferred ceiling did not move: it stays at 18.5 KB (18 944 B), with 372 B to spare. Raw went to the next publishable figure above the measurement, 55 KB, which leaves 311 B of headroom. Trims were kept: the widget count is decremented without a guard against zero (every widget the tag tracks counted once when it was placed), and the clean-up runs inside the existing scan rather than a second observer. Waiting for the new widget before removing the old one (+37 B) was not trimmed: a theme that takes the old block out and inserts the new one a moment later would otherwise see the widget vanish and come back, and the page jump more than with the duplicate (layout shift 0.131 instead of 0.056, measured in Chromium).

The raise before it moved both ceilings: transferred 18 KB → 18.5 KB (18 944 B) and uncompressed 54 KB → 54.5 KB (55 808 B), for the panel following the visible area of the screen on a phone. While the panel fills the screen, the tag moves the panel down to the top of what the shopper sees and gives the conversation inside it (the iframe) the height of that visible area, so a browser that scrolls the page behind the panel, or opens its keyboard, can no longer leave the header out of view or the message field under the keyboard. The panel itself keeps its full height and its opaque background, so nothing of your storefront shows below the conversation. The tag also keeps following the visible area every frame for 600 ms after each change, because iOS moves it during the keyboard's animation without announcing each step. Measured at the production origin: 18 269 B transferred and 55 267 B uncompressed on main, 18 460 B and 55 780 B with the change (+191 B and +513 B). Both ceilings went to the next publishable figure above the measurement, which leaves 484 B of headroom transferred and 28 B uncompressed. The transferred figure had held under 18 KB through the first drafts (18 400 B). It crossed by 28 B with the two additions that make the panel follow what iOS really does: the 600 ms follow during the keyboard's animation, frame by frame (+54 B, of which 12 B guarantee that only one loop runs at a time), and the conversation, not the panel, taking the visible height (+6 B). Trims were kept: the tag adds no frame coalescing of its own on the events (the browser already dispatches the visual viewport's events once per rendering update), the panel's max-height stays at 100dvh, one handler, created once at boot, serves both events, the follow ends on one deadline timestamp that every event pushes back, so the loop is never cancelled or restarted, and the loading spinner stays centred on 50%. The first draft, with a coalescer and a max-height write, measured 18 428 B transferred and 55 689 B uncompressed. The alignment is its own functions and can be removed without touching the rest of the change.

Before that, the uncompressed ceiling alone moved 53.5 KB → 54 KB (55 296 B), for a session handoff. A tab opened from a link of the panel starts with an empty sessionStorage, so it used to start a new session, and during a holdout test a new draw: an add-to-cart made there could land in the other group. The panel now tells the tag, with a message that carries nothing, that a link was opened, and the tag keeps the session id in localStorage for 15 seconds so that a tab of your site with no session of its own continues it. Measured at the production origin: 18 143 B transferred and 54 783 B uncompressed on main, 18 240 B and 55 193 B with the change (+97 B and +410 B). The transferred ceiling did not move: it stays at 18 KB (18 432 B), with 195 B to spare. Raw went to the next publishable figure above the measurement, 54 KB, which leaves 103 B of headroom. Trims were kept: the stored value is one string, and it is not removed when read, which is also the behavior (several links opened in a row each continue the session). The handoff is independent of the rest of the holdout change and can be removed on its own.

The raise before it, for the conversation panel's accessibility: transferred 17.5 KB → 18 KB (18 432 B) and uncompressed 52.5 KB → 53.5 KB (54 784 B). The panel is now a named dialog, modal (the rest of the page made inert) while it locks background scroll, keyboard focus moves into it once the conversation has loaded, the launcher and the Panel reduced state button say whether the panel is open, and the slide and the loading spinner respect reduced motion. Measured at the production origin: 17 725 B transferred and 53 555 B uncompressed on main, 18 051 B and 54 574 B with the change (+326 B and +1 019 B). Two trims came first. The panel's name is served with your site's configuration in each language you enabled, with only English built into the tag (six built-in names would have used most of the headroom on their own), and a redundant fallback to the site's default language was dropped (-6 B). Folding the modal switch into a single loop saved nothing and was reverted. Three costs came after the trims: splitting the opening code to pass our complexity check (22 B transferred, 38 B uncompressed), giving keyboard focus back to the element that opened the panel, added after review (48 B and 142 B), and holding focus on the page until the conversation has loaded, so Escape still closes a loading panel (6 B and 35 B). Nothing that makes the feature correct was trimmed: the tag still remembers which of your page's elements it made inert, so closing the panel never clears an inert your page set itself, the dimmed backdrop stays clickable, and a panel closed before the conversation loaded still gets no focus. The change adds no listener and no request before the shopper interacts. Headroom is 381 B transferred and 210 B uncompressed.

Before it, the uncompressed ceiling alone moved 52 KB → 52.5 KB (53 760 B), for the shopper's own cart on Shopify. The tag now reads the shopper's cart from your store's cart.js when the panel opens and sends its reference to the panel, and it creates an empty cart through the store's own cart/update.js when the visitor has none. Measured at the production origin: 17 499 B transferred and 52 991 B uncompressed on main, 17 725 B and 53 555 B with the change (+226 B and +564 B). The code was trimmed first (37 B saved by sharing one helper with the cart attribution write). The check that a cart reference carries its access key, the guard against creating two carts, and the memo that avoids sending an unchanged cart twice all stay. The transferred ceiling did not move: it stays at 17.5 KB (17 920 B), with 195 B to spare. Raw leaves 205 B of headroom.

Before that, the uncompressed ceiling moved 51.5 KB → 52 KB (53 248 B) with the widget loaded and mounted events, and the transferred ceiling moved 17 KB → 17.5 KB (17 920 B) in the same change.

The previous raise moved only the uncompressed ceiling, 51 KB → 51.5 KB (52 736 B), for the Starters field getting its own keyboard focus ring: one CSS rule and one custom property. Measured at the production origin: 17 231 B transferred and 52 083 B uncompressed on main, 17 269 B and 52 219 B with the ring, which on its own still fits the 51 KB ceiling by 5 B. The raise is for the change that ships in the same release, where the tag clears a store theme's focus outline on every widget host and on the chat panel: with both, the bundle measures 17 307 B transferred and 52 308 B uncompressed, 84 B over 51 KB. That leaves 428 B of uncompressed headroom. The transferred ceiling did not move: it stays at 17 KB (17 408 B), with 101 B to spare. No trim was attempted, since there was nothing left to take out of a single rule and its property.

The Starters card putting its free-text field on the same line as its questions when there is room shipped under that same ceiling, with no raise of its own. It needed a row element of its own, the rules for the wide layout, and a one-column fallback for a browser that cannot build the card's stylesheet in the page; a trim came first (a restated flex default dropped, and the questions' bottom margin folded into the row's gap). With the three changes together, the bundle measures 17 368 B transferred and 52 603 B uncompressed; with the optional Starters title that shipped the same day, 17 381 B and 52 649 B.

The panel's side and how it opens moving to your site's Appearance settings, served with the site's configuration instead of read from iadvize('set', …), also shipped under that same ceiling, with no raise and no new listener. The bundle measures 17 395 B transferred and 52 644 B uncompressed.

Before it, a raise happened when the two changes described next met. Each fitted its own ceiling, the Panel reduced state button at 16 504 B transferred and 50 098 B uncompressed, the Starters card's text sizes and layout at 15 527 B and 47 240 B on their own bases, but together they measure 17 058 B transferred and 51 621 B uncompressed (+554 B and +1 523 B over the button alone, close to the +529 B and +1 557 B the Starters change cost by itself). Both ceilings moved: transferred 16.5 KB → 17 KB (17 408 B, 350 B of headroom) and uncompressed 49 KB → 51 KB (52 224 B, 603 B of headroom). No further trim was attempted, since both changes had already been trimmed on their own.

Before that, the raise for the Starters card's text sizes and layout moved both ceilings: transferred 15 KB → 15.5 KB and uncompressed 45 KB → 46 KB, then 46.5 KB once it merged with the loader's "site not accepting conversations" path. The card now picks its layout from its own width rather than the window's (one column under 480 px, the questions side by side from there), its questions wrap onto two lines instead of being cut, the input field's text never drops under 16 px on a touch screen, and every size comes from the appearance you set, resolved before it reaches the tag. Measured at the production origin: 14 904 B transferred and 45 479 B uncompressed before, 15 433 B and 47 036 B with the new stylesheet and sizes, 15 424 B and 46 995 B after a trim (-9 B and -41 B), and 15 433 B and 47 005 B once the send button kept a 24 px minimum on a narrow field. Two trims were measured and rejected: sharing the two-line rule between two stylesheets grew the transferred size, and dropping the support for a configuration served before this release would have saved 124 B but removes a promised behaviour. Nothing that keeps the card correct was trimmed: the one-column layout for a browser that cannot read the card's width, the full question read to screen readers, the 16 px floor on touch screens and the exact sizes of a Starters widget saved before this release all remain.

Before those, the raise for the Panel reduced state button moved both ceilings: transferred 15 KB → 16.5 KB (16 896 B) and uncompressed 45 KB → 49 KB (50 176 B). It was measured at the production origin in steps, so each cost can be told apart: 14 986 B transferred and 45 714 B uncompressed on main; 15 060 B / 45 897 B with the new conversation signals the panel sends the tag (+74 B / +183 B); 15 274 B / 46 300 B once every shopper-facing text the tag carries (the AI disclosure, the question bar's placeholder and its send label) comes in the six languages the assistant converses in rather than three, and the accessible names join the disclosure with a full stop rather than a dash (+214 B / +403 B); then 16 513 B / 50 278 B with the button itself (+1 239 B / +3 978 B: its record and marker, its element and hide control, its placement with safe-area insets, its animation, its three events, the focus move on close and the floating widgets stepping aside). A trim came first and gave back 85 B / 402 B: one icon builder now draws both the question bar's send arrow and the hide control's ✕, and the hide control's styles moved into the button's own stylesheet. The tag's code review then added 7 B / 29 B of fixes, splitting a long function into helpers 22 B / 34 B, and a second review 47 B / 159 B of guards (store previews, an older panel after a rollback, an objection made before the page loaded), for a final 16 504 B transferred and 50 098 B uncompressed. Nothing that makes a state correct was trimmed: the reduced-motion rule, the always-visible hide control, the safe-area insets, and the storage and objection guards stay. Headroom now is 439 B transferred (16 896 B ceiling) and 237 B uncompressed (50 176 B ceiling).

The raise before it, for styling each part of a widget on its own, moved both ceilings: transferred 14.5 KB → 15 KB (15 360 B) and uncompressed 44 KB → 45 KB (46 080 B). Every visible part of a widget now takes its own look from the appearance you set: the starter questions have their own fill, text colour, border and corners instead of the control's colours swapped round; the control and the question bar can carry a border; the Starters field has its own border and corners; the Starters title has its own colour; and a hidden icon frees its space instead of leaving a gap. First measured on the 10.5 KB tag, before the question bar's engaged states landed: 10 739 B transferred before, 10 845 B after (+106 B), and 10 820 B after two trims kept on evidence (the Starters field reads its border and corners from the colour set it already picks, and the questions' fallback for an older configuration shrank to a guard that renders no questions). Merged onto the question bar's engaged states and width (14 728 B transferred and 45 045 B uncompressed on main, 120 B and 11 B of headroom), it measures 14 899 B transferred and 45 449 B uncompressed (+171 B and +404 B). Nothing that renders a part was trimmed, and the configuration the tag downloads still carries no CSS text: the one border rule is built in the tag, in one place. The raise before it, for the question bar's width following its size, moved both ceilings: transferred 14 KB → 14.5 KB (14 848 B) and uncompressed 43 KB → 44 KB (45 056 B). The tag now reads one table of figures (a floor, a ceiling, an engaged widening, a resting share and an engaged multiplier per size) and measures the bar's width in pixels at each resize. The first version of that table crossed only the transferred ceiling: 14 360 B transferred, 24 B over 14 KB, and 44 027 B uncompressed, 5 B under 43 KB, after two trims (the size lookup became an index, -12 B, and the two copies of the figures became one, -5 B). Giving each size its own resting share and engaged multiplier added two numbers per row and one multiplication, +43 B transferred and +110 B uncompressed, which put the uncompressed figure over 43 KB as well. Three more changes were measured and rejected: a fallback size would crash the bar on an unknown size, a bit-trick rounding grew the transferred size, and dropping the hidden-host guard made the clip animate on page load. Measured at the production origin when it landed: 14 403 B transferred and 44 137 B uncompressed. A later change moved no ceiling: the bar now rises above the on-screen keyboard (a visual viewport read while the field has focus, +103 B transferred and +356 B uncompressed). A later fix makes the bar's widening translations only, so its parts move together on every browser, and a tap on Android no longer flashes a square highlight: +205 B transferred and +497 B uncompressed, with no ceiling moved. The fold then learned to ignore the scroll a reload restores: +17 B transferred and +55 B uncompressed, with no ceiling moved. Measured at the production origin before the per-part styling: 14 728 B transferred and 45 045 B uncompressed. The raise before the width change, for the question bar's three states, moved both ceilings: transferred 10.5 KB → 14 KB and uncompressed 32 KB → 43 KB. At rest the bar shows no chip and types your starter questions into its field. Engaged, it reveals its chips, steps its shadow up and widens on a desktop window. On a narrow window it folds into a bubble after a scroll. It also gained a 16px field on mobile, chips that wrap onto two lines, a send button that is disabled while the field is empty, and a keyboard focus ring. Measured at the production origin, after a trim attempt that came first: 10 625 B transferred before, 13 958 B after the build (+3 333 B), 13 947 B after the trim (-11 B, the duplicated vendor-prefixed declarations and one display that was already implied), 14 000 B after three correctness fixes found by the end-to-end specs (+53 B) and 14 245 B once rebuilt on the SVG send arrow (10 739 B on main) with four long functions split in two. Uncompressed went from 32 816 B to 43 781 B. Removing each part from the finished build gives its cost in transferred bytes: the stylesheet 1 220 B (built in the browser, so no <style> element for a strict style-src to block), the typewriter 292 B, the hand-off to the panel 216 B, the scroll listener and the fold 261 B, the shrink of wrapped chips 100 B and the width measurement 93 B, the rest being the bar's markup and wiring. One more trim was measured and rejected: dropping the wrapped-chip shrink saves 100 B and still does not reach 13.5 KB, so it would only have cost the chips their even padding. Nothing that makes a state correct was trimmed: the reduced-motion rule, the isolation of keystrokes from your page, the guard that stops an empty field from sending, and the rule that respects a page that disables clicks. Headroom after it was 461 B transferred (15 360 B ceiling) and 631 B uncompressed (46 080 B ceiling).

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-11-06, from a production build of the tag that includes the clean-up of inline widgets whose target was replaced, described above:

FileTransferred (gzip)Uncompressed
loader.js293 B343 B
iadvize-<hash>.js18 572 B56 009 B
Total script weight≈ 18.4 KB≈ 55 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 18.4 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.

Both ceilings have little room left

With the clean-up of replaced inline targets, measured on 2026-11-06, the assistant bundle is 18 572 B against the 18 944 B (18.5 KB) transferred ceiling, 372 bytes of slack, and 56 009 B against the 56 320 B (55 KB) uncompressed ceiling, 311 bytes. Both are less than one feature of the size the recent raises paid for. 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, 290 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 three calls: a GET for the site's display config (branding), a GET that asks which page type your site's pages hold for the current path, and (only on the first page of a tab session) one sendBeacon POST recording that the tag loaded. That's five requests in total on a first page view, four on later pages in the same session.

The page-type request runs in parallel with the config request, and the tag waits for it for one second at most before it decides which widgets to show. A slow or failed answer leaves the page with no page type, and nothing else waits on it. On a client-side navigation the tag asks again for the new path. A shopper who objected to measurement makes no such request. The answer is cacheable by our CDN for a few minutes. Declaring a page type yourself does not skip the request.

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 sixth request in the table above; a page with no visible widget still makes five. The Panel reduced state button records that it was seen with a beacon of its own, once per tab session. While it shows, no chat bubble or question bar renders, so those send nothing.

Then nothing. No iframe, no stylesheet file, 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, a question bar and the Panel reduced state button 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, and a card with its heading switched off has no header row either. So Starters' height varies (field alone, or chips + field, each with or without a header), with 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 of Focus on the chat), 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. The default "Alongside the page" mode on a wide screen 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 Performance check card of the site's Site settings → Installation tab. See Measure it on your own storefront.

Listeners on your page

The tag registers nine listeners on your document and window, and one more when a question bar renders:

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

The tenth is a scroll listener on window, registered once for every question bar on the page, when the first one renders. It is passive, so it can't delay scrolling, and it does nothing above 767px: it is what folds an idle bar into a bubble on a narrow window. A page with no question bar never registers it. The rest of the bar's behaviour (focus, Escape, the pointer) listens on the bar's own elements, never on your document or window. The Panel reduced state button adds no listener to your page either: its two controls listen on their own elements. The bar also watches the document's width with a ResizeObserver, which isn't a listener on your page, to size its engaged width.

The tag also binds resize and scroll on window.visualViewport, a separate event target from document and window, so they are not among the nine. They exist only while the conversation panel is shown and fills the screen width (a phone, or a window 640px wide or less), and they are removed when the panel closes: they keep the panel aligned with the visible area of the screen.

Page-wide listeners are the tag's main interaction cost and they're invisible in the DOM, so the nine are pinned by a test, which runs on a page without a question bar: 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 Site settings → Installation tab and use its Performance check card.

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 (the Authorized domains card, on the same tab), 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 card is split in three tabs: 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 check 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 five requests: loader.js, iadvize-<hash>.js, the config call, the page-type call (/page-type?path=…) and the beacon. Scroll a widget into view and a sixth 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, choose Focus on the chat on the Appearance page first, save, and reload your page. iadvize('set', { backdrop: true }) no longer does this: the tag ignores it.

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

On this page