Look for this in your error tracker: Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node. There is no local repro. The affected sessions have nothing in common, until you notice they had page translation on. I spent about a month measuring what translators do to a live DOM. Chrome, Edge, Firefox, Yandex, and Google's translate_a/element.js widget. Every figure here comes from a raw recording, taken 2026-09-02 on Windows against Chrome 151. What actually happens to your node Chrome does not edit your text node. It builds a new one, wraps it, puts the wrapper where yours was, and detaches yours. Here is the markup that replaced There are 4 lights! on a page translated to Dutch: <font dir="auto" style="vertical-align: inherit;"><font dir="auto" style="vertical-align: inherit;">Er zijn 4 lampen!</font></font> Yandex does the same thing with <ya-tr-span> and keeps your source string in a data-value attribute. React's fiber still points at the detached original. That gives you two failures. removeChild and insertBefore throw, because the node has no parent any more. And node.nodeValue = '...' throws nothing at all, while reaching nobody. The crash is the loud half. The freeze is the one that never reaches your error tracker. The JSX that triggers it Two shapes. A conditional text node with a sibling: <p> {visible && 'There are 4 lights!'} <span> (status)</span> </p> The <span> matters. Without a sibling, React clears the parent with textContent and nothing throws. With one, the string becomes a real text node and React calls removeChild on it. And any text with a value in the middle: <p>There are {count} lights!</p> The literal text on both sides forces three separate text nodes. That is what freezes. shuhei described the first shape in facebook/react#11538 in 2018. Dan Abramov closed it won't-fix. The guard everyone pastes From that thread: const original = Node.prototype.removeChild Node.prototype.removeChild = function (child) { if (child.parentNode !== this) return child return original.apply(this, arguments) } One React app, three setups, same translation: The guard stops the throw. It leaves the app frozen, keeps deleted text on screen, and lets a flipped ternary render both branches. It trades a crash you can see for a bug you cannot. Not every browser Detaching In place Engines Chrome, Yandex, the Google widget Edge, Firefox What lands in the DOM a wrapper element, original detached the text node is rewritten, no wrapper Marker inline vertical-align: inherit, or eight data-* attributes _msttexthash on Edge, nothing on Firefox Your write reaches the screen no yes Edge and Firefox leave the node connected, so your writes land. A counter driven once a second reached 6 in both. They are narrower, not immune: when they merge adjacent text runs they detach too, 2 nodes against Chrome's 3 on the same probe. One detection trap, since the usual advice is to spot Google's wrapper by its vertical alignment: getComputedStyle(wrapper).verticalAlign // "baseline", the cascade resolved it wrapper.style.verticalAlign // "inherit", the fingerprint you want Chrome only translates what is on screen I fired ten signals at an idle translator, plus a no-signal control, each against the same untranslated node. One worked. Nine did nothing for 10 seconds, including a real mouse wheel and a real mouse move generated by the browser. So trusted input is not the trigger, and neither is scrolling the window. The element has to enter the viewport. Chrome's translator is not a MutationObserver over your page. It re-scans what becomes visible. The conclusion I got wrong I had written down that Chrome never repairs a restored text node. I had a probe proving it. The probe sat below the fold. With the element in view, Chrome repaired the restored node in 210ms. Off screen it never did. A probe that only ever ran below the fold would have shipped the wrong claim, and for two days it did. What a repair costs the reader Once the node is detached you have two options. Restore the original and let the translator catch up, or write the new value into the wrapper the translator already built. Five replicates of four updates, sampled every 50ms: Across the 20 updates Restore and retranslate Write into the wrapper Shortest update 100ms of source language 0ms Median update 150ms 0ms A sequence of four 500 to 600ms 0ms A price that changes once is a flash nobody reports. A counter pays it every tick. Where writing into the wrapper stops working Splicing a new digit into an already-translated sentence works in Dutch. In Russian it breaks the grammar. Intl.PluralRules('ru') puts 4 in few and 7 in many, and the noun follows the category, so Здесь 4 лампочки! must not become Здесь 7 лампочки!. An early build of mine shipped exactly that. The merge now refuses when the plural category, the digit count or the sentence shape changes, or when the locale is unknown. On refusal the reader gets the right number in the source language. That is a real loss, and Dutch and German report other at every count, so it stays invisible in the languages this was developed against. Two things worth checking in your own app Attributes get translated too: alt, title, placeholder, aria-label and a submit value. data-* attributes and meta[name=description] are left alone. Every engine also rewrites <html lang>, and Chrome appends class="translated-ltr". If you use Next.js, that lands before hydration and gives you a mismatch warning that no runtime library can prevent. suppressHydrationWarning on the root element is the fix, and it only covers that one element's own attributes. What came out of it translate-shield forwards React's writes into the wrapper instead of restoring the node. Zero dependencies, about 15 kB packed. On Edge and Firefox it does nothing, which is correct there. npm install translate-shield import { initTranslateShield } from 'translate-shield' initTranslateShield() It does not translate anything and it does not replace an i18n library. If you only want the lint rule, eslint-plugin-react-google-translate catches the risky JSX shapes before they ship, and is worth running either way. There is a live demo that runs the protected and unprotected versions side by side in your own browser, in two separate documents, because the patch covers a whole document and one page could not host an honest control. Every figure above comes from a JSON file in the repo, produced by a Playwright spec you can re-run. Including the two I had to correct.