Review sources
Reviews widget not updating on dynamic pages: AJAX, SPAs, infinite scroll
A reviews widget stops updating on a dynamic page because its script runs once, when the document loads, and never looks at content that JavaScript inserts afterwards. Infinite scroll, AJAX filters, quick-view modals and client-side routing all change the page without loading a new document, so a container that arrives later simply stays empty. The fix is to call the widget’s init function again once the new container exists, and to call it exactly once per container, because a second call on a filled container stacks a duplicate. On 22 October 2026 we ran our own live embed script through each of these situations in a headless browser and counted widgets, slides, bytes and API requests.
Why an embed script misses content added later
We started with our own source rather than with theory. Every init function in justreview.js
does three things in a fixed order: it requests review data from our API, it looks up its container
with a single document.querySelector, and it writes markup into that container. After that the
library is done. It does not register a MutationObserver, it does not listen for route changes,
and it never looks at the page again.
For an ordinary page that loads once, this is the right design: one call, one request, no
background work. On a dynamic page it produces two failure modes that are mirror images of each
other. A container that appears after the call stays empty, which looks like an outage. A call that
lands on a container that is already filled adds a second copy, because the builders write with
innerHTML +=, appending rather than replacing. One of these is a missing widget and the other is
a doubled one, and the cause is the same in both cases: the call and the container are out of step.
Our setup was a plain HTML page on localhost that loaded the production justreview.js and
justreview.css from our CDN, driven by Chromium through Playwright at a 1280 px viewport. The widget
was the testimonials carousel of our demo account with 20 reviews. A healthy mount gave one
carousel, 29 slide elements including the loop clones, 361,938 bytes of HTML inside the container
and one API request. Everything below is compared against that baseline.
What we measured on 22 October 2026
| Situation | Carousels | Slide elements | HTML in container | API requests | Console errors |
|---|---|---|---|---|---|
| Static page, one init call | 1 | 29 | 361,938 bytes | 1 | 0 |
| SPA: leave the route and come back, no new call | 0 | 0 | 0 bytes | 0 new | 0 |
| SPA: same, init called after the route change | 1 | 29 | 361,938 bytes | 1 new | 0 |
| AJAX hook calls init three times on one container | 3 | 87 | 1,085,814 bytes | 3 | 0 |
The column that matters most is the last one. Not one of these situations logs an error, which is
why they are so hard to diagnose from a support ticket. The vanished widget after a route change
leaves a clean, empty <div> and a quiet network tab, so it looks exactly like our service being
down. The tripled widget looks like a theme bug: the section grew to 2,591 px tall and carried more
than a megabyte of duplicated markup, and none of that raised a warning.
The SPA rows also show the good news. Calling the init function after the new route had rendered was enough to restore the identical carousel, byte for byte, at the cost of one extra request. The whole problem is when to call it and how to avoid calling it twice.
Re-initialise once per container, never once per event
The duplicate is not caused by calling the function often. It is caused by calling it on a node that already holds a widget. So the guard belongs on the node, not on a timer:
<script>
function mountReviews() {
var el = document.getElementById('justreview-testimonials');
if (!el || el.dataset.jrMounted) return; // no container yet, or already filled
el.dataset.jrMounted = '1';
JustReview.initTestimonials('YOUR_USER_ID', { /* your config from the panel */ });
}
</script>
Storing the flag as a data attribute on the container, rather than in a global variable, is what makes this work on dynamic pages. When a route change or an AJAX swap throws the old container away, its flag goes with it, and the fresh container is unmarked and gets its one call. We tested exactly that: three round trips away from the route and back, with the guard in place, produced one carousel every time and three API requests in total, one per return. Without the guard and with a global “already initialised” flag instead, the second visit would stay empty, because the flag outlives the node it was describing.
Use the call from your panel’s generated snippet as the body of this function. The library exposes
its functions only on the JustReview object: in our test window.initTestimonials was undefined and
JustReview.initTestimonials was a function, so call it the way the generated snippet does. The full
list of functions and options is in our
widget JavaScript reference.
MutationObserver: watch the grid, debounce, then guard
When your theme gives you no event to hook into, a MutationObserver is the generic fallback. It is
also the easiest way to make things worse. We simulated an infinite-scroll category page: 24 product
cards arriving in four batches of six, one card at a time, with the reviews carousel sitting outside
the grid. Each variant below observed the grid and called the init function from its callback.
| Observer setup | Init calls | Carousels | API requests | HTML in container |
|---|---|---|---|---|
Grid, childList, no debounce | 25 | 25 | 25 | 9,048,450 bytes |
| Grid, 150 ms debounce | 5 | 5 | 5 | 1,809,690 bytes |
| Grid, 150 ms debounce plus the mounted guard | 1 | 1 | 1 | 361,938 bytes |
document.body with subtree, no guard (capped at 30) | 30 in 2.2 ms | 30 | 30 | 10,858,140 bytes |
Debouncing alone helps less than people expect. It cut 24 observer callbacks down to four extra calls, one per batch, and every one of those calls still appended a full carousel, so the page ended with five. A debounce limits how often you call. Only the guard limits what a call can do.
The last row is the one to avoid entirely. Observing the whole body with subtree: true means the
observer sees the widget’s own writes. The init call inserts a loading spinner, the spinner is a DOM
change, the observer fires and calls init again, which inserts another spinner. Our test reached its
safety cap of 30 calls in 2.2 milliseconds. In our first attempt we had set the cap at 400, and the tab
stopped responding altogether until we killed the browser several minutes later. Without a cap, that
is what a visitor gets.
The pattern that survived is short. Observe the narrowest element that actually changes, wait for the batch to settle, and let the guard decide whether anything needs mounting:
<script>
var grid = document.querySelector('#product-grid'); // the element your theme refills
var timer = null;
if (grid) {
new MutationObserver(function () {
clearTimeout(timer);
timer = setTimeout(mountReviews, 150);
}).observe(grid, { childList: true });
}
mountReviews();
</script>
Our help centre has the short version of this fix next to the other reasons a widget can stop showing, such as a changed domain whitelist or an optimisation plugin deferring the script.
Use the hook your stack already fires
An observer guesses when the page has changed. Most stacks tell you outright, and those signals are
cheaper and more precise. Put mountReviews() in whichever of these your site already uses:
- jQuery-based themes and plugins:
$(document).on('ajaxComplete', mountReviews)runs after every jQuery AJAX request, which covers most filter and load-more plugins built on it. - Hotwire Turbo:
document.addEventListener('turbo:load', mountReviews)fires on the first load and after every visit. - Vue Router:
router.afterEach(() => nextTick(mountReviews)), because the new view is only in the DOM after the next tick. - Infinite Scroll by Metafizzy:
infScroll.on('append', mountReviews)after each new page of items. - swup 4:
swup.hooks.on('page:view', mountReviews)once the new content is visible. - Your own fetch code: call it on the line after you insert the new HTML.
React and Next.js are a special case, because StrictMode runs effects twice on purpose and the server
pass has no window at all. We measured those failures separately in
our React and Next.js widget test, and the ref-based guard there
is the React equivalent of the data attribute above.
Inline stars do not belong in every product card
A tempting idea on shop themes is to put our inline stars into each card of a category grid and
re-initialise them as the grid grows. It does not work, and the reason is what the stars show. The
stars widget displays your overall rating across the sources you connected, not a rating per product,
and it renders into one element with the id justreview-stars.
We tested it anyway. In a grid of 12 cards that each carried that container, the stars appeared in the
first card and in none of the other 11. After 12 more cards arrived and we called initStars again,
the first card held two rows of stars and the other 23 cards held none. Even if it had filled every
card, it would have told shoppers that each product carries your store-wide score, which is not what
the number means. Place the stars once, near your logo or your main heading, and leave them out of any
re-initialisation logic.
Widgets inside hidden modals and tabs
A quick-view modal or a tab panel that starts hidden is dynamic in a quieter way: the container exists at load, but it has no width. The carousel measures its container when it starts, so a widget initialised while hidden lays itself out for a width of zero. When we opened the modal, each review card was 1,160 px wide and the carousel showed one review per view instead of three.
Two fixes worked. Calling the init function when the modal opens, through the same guard, produced the
normal layout of three cards at 387 px each. Leaving the early init in place and running
window.dispatchEvent(new Event('resize')) right after the modal became visible also restored the
three 387 px cards, because the slider recalculates on resize.
If the page already has a carousel and the modal needs its own, use the third argument of the init
function. The second container keeps the same id plus an extra class, and the function gets that class
with a leading dot, for example JustReview.initTestimonials('YOUR_USER_ID', config, '.qv') for
<div id="justreview-testimonials" class="qv">. We placed a plain container and a qv container on one
page, called the function with '.qv', and only the second one was filled.
What each re-initialisation costs
Every init call is a full round trip to our API plus a rebuild of the markup. Across ten clean runs on our test machine, the time from the call to the first slide in the DOM had a median of 70.5 ms, with a range of 67 to 91 ms. Once per route change, that is invisible to a visitor. Twenty-five times during one scroll it becomes 25 requests and nine megabytes of duplicated DOM, which is the difference between the guarded and unguarded rows in the second table. If you are already watching how scripts weigh on your pages, our page speed measurements cover the cost of the first load.
Why our widget on a dynamic site
The reason to pick us for a dynamic site is that you can see and control everything above. The widget
renders into your own <div>, not into an iframe, so you can count its carousels, bytes and requests
in the same test you already run, and fix any of the cases in this post with ten lines of your own
code. One script tag, one config object, no framework package to keep in step with your React or Vue
version, and the same three installation steps on a single-page app as on plain HTML, described in our
guide to embedding Google reviews on any website.
The limits are worth stating plainly. The library has no built-in observer, so on a dynamic page the re-initialisation is your code, not ours. The append behaviour that causes the duplicate is ours to change, and until we do, the guard is not optional. And inline stars are a single, page-level element by design.
Every plan, including the free one, is on the pricing page. To try the guard and the observer above against a real account, connect your sources and paste your own ID into the snippet, create a free JustReview account and have your reviews on the page in a few minutes.
FAQ
Why does my reviews widget disappear after an AJAX update or a route change?
Because the script ran once, at page load, and the container it filled has since been replaced. An embed script does not watch the page. On 22 October 2026 we left a route in a test single-page app and came back: the new container held 0 widgets and 0 slides, the console showed 0 errors and our API received 0 new requests. One init call after the route change restored the full carousel at the cost of one extra request.
Is it safe to call the widget init function again?
Only on an empty container. Our init functions append their markup instead of replacing it, so a second call on a container that already holds the widget adds a second copy. Three calls on the same container produced 3 carousels, 87 slide elements, 1.09 MB of HTML and a section 2,591 px tall, with no warning in the console. Mark the container when you mount it and skip it the next time.
Should I use a MutationObserver to reinitialise a reviews widget?
Only on a narrow container, with a debounce and an already-mounted guard. Observing document.body with subtree turns into a loop, because the widget's own writes trigger the observer: our test hit its safety cap of 30 init calls in 2.2 ms. Watching only the product grid with a 150 ms debounce and a guard turned 24 inserted product cards into exactly one init call and one API request.