Embedding a support widget in single-page apps: route changes, Shadow DOM, load time
What a support widget has to manage in a single-page app: survive route changes, isolate its styles, not slow the page down. With the technical reasons behind it.
Martin Semmele

Contents
- 01Why a third-party script lives differently in a single-page app
- 02Route changes: the widget does not belong in the application tree
- 03Shadow DOM: what style separation achieves and what it does not
- 04Load time: one line decides
- 05Integration via a tag manager
- 06Consent and loading after a click
- 07The checklist after installation
- 08Frequently asked questions
Key takeaways
- 92 per cent of all web pages load at least one third-party resource; a support widget is one of them and has to behave accordingly.
- A widget that is mounted once on the document and does not live in the application tree survives every client-side route change without any intervention.
- Shadow DOM separates styles in both directions, but not keyboard focus, fonts or stacking order against a cookie banner.
- A script with the async attribute does not block page parsing; that is the one line that decides the load time.
- Double mounting, lost conversation identifiers and a font package pulled in from a third party are the three mistakes that occur most often in practice.
Why a third-party script lives differently in a single-page app
On a classic website, every click loads a new page. A script placed at the end of the document therefore runs again on every page view, and anything it wants to remember it has to store somewhere. In a single-page app it is the other way round: the document is loaded exactly once, after which the framework only swaps out parts of the tree. A script that has run once does not run again.
For a support widget, that is initially good news. It does not have to rebuild itself on every route change, and an open conversation simply stays open. The bad news follows from the same circumstance: everything the widget gets wrong also persists, until someone hard-reloads the page. A style conflict, a second launcher in the bottom right corner, a focus stuck inside the widget, none of that is cleared up by a route change.
According to the Web Almanac 2024, 92 per cent of the pages studied load at least one third-party resource, and among the thousand largest pages the median is 66 distinct third parties1. Your support widget is one of them. It competes with analytics scripts, cookie banners and font packages for load time, stacking order and keyboard focus. The next three sections take these three conflicts apart one by one.
Route changes: the widget does not belong in the application tree
The most common mistake when embedding the widget in React, Vue or Angular is well meant: the widget is brought into the application as a component so that it sits 'cleanly' in the tree. That ties it to the lifetime of that component. When the route changes and the layout re-renders, the widget is unmounted and mounted again. The visible effect: the chat window closes, the history is gone or appears twice, and depending on the implementation a second conversation is opened.
Mount once on the document, not in a component
The robust variant is the simplest: the script is loaded once, attaches its container directly to the body element and thus lives outside everything the framework manages. A route change swaps nodes inside the application container; the widget container next to it remains untouched. That is exactly how the Comlayer widget works: it reads its own script tag via document.currentScript, creates a single host element on the body and checks before mounting whether that element already exists. If the script runs a second time, for example because a framework repeats the embed code on a re-render, nothing happens.
Two subtleties matter here. Firstly, document.currentScript returns nothing when the code runs from a callback, an event or a JavaScript module2. A widget that relies on it needs a fallback, such as searching for the last script tag with the matching data attribute. Secondly, the page address assigned to a conversation is the address at the moment the conversation was opened. If the visitor then changes route five times, the inbox keeps showing the starting address. That is not a bug but a decision; anyone who needs the current address has to ask for it in the message itself.
Where the conversation history lives
For a conversation to survive a hard reload, the widget has to remember a visitor identifier and a conversation identifier. The usual place is the browser's localStorage, stored under a prefix that contains the widget key. Two widgets on the same domain, for example on a marketing page and in the application behind it, then do not get in each other's way. What does not belong there is the conversation content itself: that is held by the provider, and the widget loads it again using the identifier. If the storage disappears because the visitor clears it or browses in private mode, a new conversation begins on the next visit, and the old one remains in the team's inbox.
| Integration location | Behaviour on route change | Behaviour on hard reload |
|---|---|---|
| As a component in the application tree | Unmounted and remounted depending on the layout; history jumps or doubles | Remounted, history reloaded from storage |
| As a script on the document, mounted once | Untouched, conversation stays open | Remounted, history reloaded from storage |
| Inserted via a tag manager | As on the document, provided the tag fires only once | As on the document |
Shadow DOM: what style separation achieves and what it does not
A widget brings its own styles, and your site has its own. Without separation, a global button { border-radius: 0 } in your stylesheet also hits the widget's launcher, and conversely an overly broad selector in the widget's styles colours your forms. The standard way to prevent this is Shadow DOM: the widget renders into its own subtree, whose styles cannot be reached from outside and which itself does not reach outward. The Mozilla documentation summarises it like this: the page's CSS does not affect nodes in the Shadow DOM, and styles in the Shadow DOM do not affect the rest of the page3.
Four things the boundary does not stop
The style separation is complete, but it is only a style separation. Four things still cross the boundary, and all four show up in support tickets to widget vendors:
- Inherited properties. Font family, font size and text colour are inherited from the host element unless the widget sets them itself. A widget that does not explicitly set its font looks different on every site.
- Keyboard focus. The Tab key runs through the whole page, Shadow DOM or not. An open chat window that does not hold the focus itself lets the keyboard wander through the page's navigation behind the window.
- Stacking order. A cookie banner with
z-index: 99999covers the launcher if the widget picks a lower value. That is why widgets set their host to the largest possible value and isolate it withisolation: isolate, so that this extreme value does not leak into the page's stacking contexts. - Language and writing direction.
langanddirare inherited from the host element. A page in Hebrew or Arabic therefore also switches the widget to right-to-left, whether it can handle that or not.
A Shadow DOM in open mode is not a security feature: the page's JavaScript can still reach inside via shadowRoot. The mode only governs access by script, not the style separation, and that holds in both modes3.
Load time: one line decides
A third-party script costs load time. The only question is whether it costs that time in the background or in the foreground, while the visitor stares at an empty page. The difference is one attribute: a script tag without async or defer halts the parsing of the document until the script has loaded and executed. The Chrome developers' recommendation is therefore unambiguous: always load third-party scripts asynchronously, unless the script has to run before the page can be rendered4. A support widget never has to.
Async, not defer, and why the widget does not lazy-load fonts
async executes the script as soon as it has loaded, regardless of its order in the document. defer waits until the document has finished parsing and keeps the order4. For a widget that needs nothing from the page except the body element, async is the right choice; it may happily finish before the rest. The Comlayer embed therefore consists of a single tag with exactly this attribute, for example <script src="https://app.comlayer.app/comlayer-widget.js" data-app-id="wgt_…" async></script>. There is no JavaScript interface for opening, closing or reporting a route change, and it is not missing: the widget does not need it, because it lives outside the application.
The second item on the load-time bill is often overlooked: fonts. A widget that pulls its house font from a font service via @import triggers a further request to a further service on every customer site, for every single visitor. That costs time and, depending on the service and where it is based, is additionally a data transfer that would have to appear in the customer site's privacy notice. Comlayer has removed this import from the widget and falls back to the system font; the built bundle is thereby around 34 kilobytes, transferred compressed, measured on 17 September 2026.
| Metric | Threshold for 'good' | What a widget contributes to it |
|---|---|---|
| Largest Contentful Paint (LCP) | 2.5 seconds | Only if the script loads synchronously or pulls in its own fonts |
| INP (responsiveness to input) | 200 milliseconds | Long script execution on the main thread at start-up |
| Cumulative Layout Shift (CLS) | 0.1 | A launcher that claims space after loading and shifts content |
The three thresholds come from the Core Web Vitals and each apply to the 75th percentile of page loads, separated by mobile and desktop5. A widget that loads asynchronously, positions its launcher fixed and pulls in no fonts does not show up in any of the three figures.
Integration via a tag manager
Many teams do not embed third-party scripts in the source code but via a tag manager. That is fine for a support widget, with two conditions. Firstly, the tag must fire exactly once, when the document loads, and not on every virtual page view the single-page app reports to the tag manager. A tag that listens for the 'page view' event inserts the script again on every route change. A widget that catches a double mount itself forgives that; one that does not shows two launchers afterwards.
Secondly, a script inserted via a tag manager may no longer run as the script tag it would have been in the source code. A widget that reads its key via document.currentScript then finds nothing and needs the fallback described above. After embedding, check in the console that exactly one host element hangs on the body and that the widget has found its key. Both can be seen in the developer tools in seconds.
Consent and loading after a click
In the European Union, embedding raises a question that has only marginally to do with technology: may the script load immediately, or only after consent? The legal classification is disputed, and we have set it out in detail in a separate article on the support widget and consent. Technically, the cautious variant means: the script tag is not written statically into the document but generated by the consent tool once the visitor has agreed.
For a single-page app this changes nothing about the basic principle. A script tag generated later also attaches its host element once to the body and stays there across every route change. What changes is the timing: the widget only appears after the click, and a conversation that was not possible before consent cannot lose any history either. Whoever chooses this variant should nevertheless keep the launcher's space free so that the page does not jump when the widget appears.
The checklist after installation
Whether an embedding is clean can be checked in a few minutes. The following points cover the mistakes that occur most often in practice:
- 01Open a conversation, then change route three times. The window stays open, the history stays put, no second conversation is created.
- 02Hard-reload the page. The history is back after loading, and the conversation identifier in storage is the same as before.
- 03Check in the developer tools that exactly one host element hangs on the
body, also after several route changes and a change back to the start route. - 04Open the cookie banner while the chat window is open. Both must remain usable, neither may cover the other.
- 05Tab through the open chat window with the Tab key. The focus must be visible and must not disappear into the page behind the window.
- 06Check in the network tab that no font file is loaded from a third-party service after the widget script.
- 07Measure the page with the Lighthouse tools, once with and once without the widget. The three Core Web Vitals must not get worse.
Comlayer passes this list because the widget is built for exactly that: one script tag, one host on the body, a Shadow DOM with its own styles, no third-party fonts, no interface that a framework would have to operate. What the widget looks like and which building blocks it carries you set in the dashboard, without rebuilding the page; what it does with visitors' data is on the security and privacy page.
Frequently asked questions
Do I have to re-initialise the widget on every route change?
No. A widget that is mounted once on the document and does not live in the application tree persists across every client-side route change. Initialising per route is only necessary if the widget was built into the application as a component, and that is exactly what should be avoided.
What happens if the script is inserted twice?
That depends on the widget. A cleanly built widget checks before mounting whether its host element already exists and does nothing the second time. A widget without this check shows two launchers and may open two conversations. Tag managers that fire on every virtual page view are the most common cause.
Does Shadow DOM protect my stylesheet completely from the widget?
For selectors, yes: the page's styles do not reach the Shadow DOM, and the widget's styles do not reach the page. Inherited properties such as font family and text colour, keyboard focus, stacking order and the lang and dir attributes still cross the boundary.
Async or defer for the widget script?
Async. The widget needs nothing from the page except the body element and may run as soon as it has loaded. defer would not be wrong, but waits unnecessarily for parsing to finish. What matters is only that one of the two attributes is set; without both, the script blocks rendering.
How do I tell whether the widget is worsening the load time?
Measure the page with the Lighthouse tools once with and once without the script and compare LCP, INP and CLS. An asynchronously loaded widget with a fixed launcher and no fonts pulled in does not measurably change the three values.
Can I load the widget only after consent?
Yes. The script tag is then not written statically into the document but generated by the consent tool once the visitor has agreed. A tag generated this way also mounts the widget once on the document, and it survives every route change afterwards just like a statically embedded one.