I build QRflows, a dynamic QR code platform. Last week I was going through session recordings and found our most engaged user of the month: someone in Hong Kong who spent 11 minutes in the dashboard, clicked 82 times, tried half a dozen QR types, added a logo, built a landing page.

At minute three, the page crashed and reloaded.

The error in the console:

NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node. 

The recording made the cause obvious once I noticed it: the whole dashboard was in Chinese. Our site only exists in English. The user had Chrome's built-in Google Translate turned on.

This is one of those bugs that never shows up in your own testing, because you never translate your own app. Here's what's going on, how to reproduce it without Google Translate, and the three-layer fix we shipped.

Why Google Translate breaks React

React keeps references to the DOM nodes it created, including plain text nodes. When state changes, it updates or removes those exact nodes.

Google Translate doesn't know about React. When it translates a page, it walks the DOM and replaces text nodes with its own <font> elements containing the translated text:

<!-- What React rendered --> <button>Save</button> <!-- What the DOM looks like after Google Translate --> <button><font><font>保存</font></font></button> 

The original "Save" text node is gone from the DOM, but React still holds a reference to it. The next time React needs to remove or replace that text, it calls parent.removeChild(textNode) — and the browser throws, because that node is no longer a child of anything.

The same thing happens with insertBefore when React tries to insert something next to a node that Translate has swapped out.

It's a long-standing issue — facebook/react#11538 has been open since 2017 — and it isn't specific to React: anything that holds references to text nodes is vulnerable. If you want the deep dive, Martijn Hols' write-up is excellent.

Which code is vulnerable

The crash needs two things: a bare text node and something conditional next to it. Patterns like these:

// Text sitting next to a conditional element <button> {isSaving && <Spinner />} Save </button> // Conditional text {error && error.message} // Ternary returning strings <span>{isSaving ? "Saving…" : "Saved"}</span> // Text mixed with an expression <p>Uploaded {count} files</p> 

Every QR builder has dozens of these: status labels, counters, "Uploading… / Done", plan badges. Which is why the crash hit our most active user and nobody else.

Reproducing it without Google Translate

Waiting for a translated session to show up in your logs is not a test plan. You can simulate what Translate does to the DOM in the browser console:

function fakeTranslate(root = document.body) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => node.nodeValue.trim() && node.parentElement && !node.parentElement.closest('script, style, [translate="no"], font') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT, }); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); for (const textNode of nodes) { const outer = document.createElement('font'); const inner = document.createElement('font'); inner.textContent = '译' + textNode.nodeValue; // mark it as "translated" outer.appendChild(inner); textNode.parentNode.replaceChild(outer, textNode); } return nodes.length; } window.addEventListener('error', (e) => console.log('CRASH:', e.message)); fakeTranslate(); 

Paste it, run fakeTranslate(), then use the app: switch tabs, toggle options, type into inputs, navigate between routes. Run fakeTranslate() again after each step, because real Translate keeps re-translating new content as it appears.

On our dashboard, switching QR types after this reproduced the exact crash from the recording within a few clicks.

The fix: three layers

There's no single clean fix. What worked for us is three layers, from "never crash" to "fewer weird states".

Layer 1: a DOM guard so the crash can't happen

This is the workaround from the React issue thread: patch removeChild and insertBefore so that, instead of throwing, they skip the operation when the node relationship is no longer what React expects.

// src/lib/domTranslateGuard.ts export function installDomTranslateGuard(): void { if (typeof window === 'undefined' || typeof Node !== 'function') return; const proto = Node.prototype as any; if (proto.__translateGuardInstalled) return; proto.__translateGuardInstalled = true; const originalRemoveChild = proto.removeChild; proto.removeChild = function (child: Node) { if (child.parentNode !== this) { console.warn('[dom-guard] removeChild skipped', child, this); (window as any).clarity?.('event', 'dom_guard_removeChild'); return child; } return originalRemoveChild.call(this, child); }; const originalInsertBefore = proto.insertBefore; proto.insertBefore = function (newNode: Node, referenceNode: Node | null) { if (referenceNode && referenceNode.parentNode !== this) { console.warn('[dom-guard] insertBefore skipped', referenceNode, this); (window as any).clarity?.('event', 'dom_guard_insertBefore'); return newNode; } return originalInsertBefore.call(this, newNode, referenceNode); }; } 

Two details that matter:

Install it before React touches the DOM. We call it as the very first thing in the client entry, before hydrateRoot:

// entry-client.tsx import { installDomTranslateGuard } from './lib/domTranslateGuard'; installDomTranslateGuard(); // ...then hydrateRoot(...) 

Keep it out of SSR. It only lives in the client entry, and the typeof window check is a second line of defence. The server never touches Node.prototype.

The trade-off is honest: this hides the error instead of fixing the cause. A skipped insertBefore can occasionally leave a piece of UI missing until the next render. That's why we log it and send a custom event to our session analytics — so we can see how often the guard actually fires, and on which pages.

Layer 2: stop giving Translate bare text nodes

The guard prevents crashes. Wrapping text in elements prevents most of the situations where it would have to step in. If text lives inside its own <span>, Translate replaces the text inside the span, and React only ever adds or removes the span itself — which is still there.

// Before <button> {isSaving && <Spinner />} Save </button> // After <button> {isSaving && <Spinner />} <span>Save</span> </button> 
// Before {error && error.message} // After {error && <span>{error.message}</span>} 
// Before <p>Uploaded {count} files</p> // After <p><span>Uploaded {count} files</span></p> 

No visual change, no new classes. We went through every component in the QR builder, the file upload flow, the editor and the download modals, and wrapped anything that changes at runtime: status labels, counters, button text next to spinners.

Layer 3: translate="no" — but only where it makes sense

The tempting shortcut is <html translate="no">. Don't. Your users turned on translation because they need it — breaking the crash by breaking the translation just moves the problem.

We only opt out content that shouldn't be translated anyway:

  • URL, email, phone and password inputs
  • short links and destination URLs
  • the QR preview and technical labels (sizes, PNG/SVG)
  • the brand name in the header and footer
<input type="url" translate="no" /> <span translate="no">{shortUrl}</span> 

Everything else — headings, buttons, help text — still gets translated.

How we verified it

After deploying, I ran the fake-translate script on the live site: the public PDF QR generator, then the logged-in dashboard. I switched QR types back and forth, toggled the logo option, typed into inputs, and navigated from the builder to the landing page editor and back — re-running fakeTranslate() after each step.

Result: zero uncaught errors, no reloads, and the UI kept updating live with the "translated" text in place. The brand name and URL fields stayed untouched, as intended.

We'll keep watching the dom_guard_* events in session analytics. If they show up a lot on a specific page, that page still has bare text nodes to wrap.

TL;DR

  • Google Translate replaces text nodes with <font> elements. React still holds the old nodes and crashes on removeChild / insertBefore.
  • You can reproduce it without Translate by wrapping text nodes in <font> from the console.
  • Layer 1: patch removeChild / insertBefore before hydration, client-only, and log when it fires.
  • Layer 2: wrap conditional and dynamic text in <span>.
  • Layer 3: translate="no" only on inputs, URLs and brand names — never on the whole app.

The part that stuck with me: this bug hit the single most engaged user we had that month. International users who translate your app are often exactly the people working hardest to use it.

I'm building QRflows in public — dynamic QR codes with Smart Rules, real-time analytics and a Claude MCP integration. If you've hit this bug with a different framework, I'd like to hear how you handled it.