September 4, 2026
The bug that only appears for readers who translate your page
A month of measuring browser translators, every recording published, and one conclusion I had to take back
By Aliev Davlatbek
5 min read
The first report was easy to ignore. A counter in a React app showed the wrong number. Everythingelse on the page worked. The value was right in state and wrong on screen. The reader had Chrome's translator turned on.
A week later the same app started throwing NotFoundError: Failed to execute 'removeChild' on 'Node' and unmounting itself. Same cause, louder symptom.
What the translator does
Chrome's translator does not edit your text node. It builds a new one, wraps it in a <font> tag, puts the wrapper where your node was, and detaches yours.
Your node is still in memory. React still holds a reference to it. It is just no longer in the document.
That single fact produces both symptoms. removeChild throws, because the node React wants to remove has no parent any more. And writing to nodeValue throws nothing at all, while changing text that nobody can see.
The crash gets a stack trace. The freeze gets nothing. A counter that stops counting looks like a state bug, so that is where people go looking.
The fix everybody pastes
shuhei described this in the React issue tracker in 2018. Dan Abramov closed it as won't-fix, and the workaround from that thread is still the one people copy. It makes removeChild and insertBefore do nothing when the node is not a child.
It works. The app stops crashing.
It also stops updating.
I ran one React app in three setups against the same translation. With no protection, two uncaught throws tore down the whole root, buttons included. With the pasted guard, zero errors and zero updates: the counter stuck, deleted text still on screen, and a flipped ternary rendering both branches at once.
The guard trades a crash you can see for a bug you cannot.
Measuring instead of guessing
I decided to find out what these engines actually do, rather than what a Stack Overflow answer says they do. Chrome, Edge, Firefox, Yandex, and Google's own translation widget. A recorder page that snapshots every text node, waits for a human to turn translation on, then runs sixteen probes and five experiments. Playwright driving real Chrome through a seeded profile for the timings.
The first useful result was that this is not a universal bug.
Edge and Firefox rewrite the text node in place and leave it connected. Your writes land. A counter driven once a second reached 6 in both. Those readers never see the crash and never see the freeze. That is inconvenient for anyone selling a fix. It is also the truth, so it went in the README and on the demo page.
The finding that changed the shape of the problem
Chrome translates the viewport, not the page.
I fired ten signals at a translator I had proven was idle, plus a no-signal control arm. Forced layout reads. Synthetic resize, focus, visibilitychange, mousemove. Scrolling the window and scrolling back. A real mouse wheel and a real mouse move, generated by the browser itself.
One worked. element.scrollIntoView(), at 168 milliseconds. The other nine did nothing for ten seconds.
Two of the failures were genuine browser events, so trusted input is not the trigger. Scrolling the window is not it either. The element itself has to come into view.
The part where I was wrong
By then I had written down a headline conclusion: once you restore a text node the translator took away, Chrome never translates it again. I had a probe that proved it. The probe ran, the node stayed English, the claim went in the document.
The probe sat below the fold.
I only found it because I had just written down that Chrome ignores anything off screen, and the two claims could not both be true. I moved the probe into the viewport and ran it again. Chrome repaired the restored node in 210 milliseconds. Off screen it never did, however long I waited.
The conclusion had been wrong for two days, in a document whose entire argument is that you should measure rather than assume.
Two more fell the same way. A comparison between my approach and an existing library was meaningless on its first run, because that library had silently failed to load. Its bundle ends with a //# sourceMappingURL= comment, and the line I appended to expose it globally landed inside that comment. Every run now proves which DOM methods each arm actually patched before it measures anything.
And I had overclaimed about Firefox. Three runs of a counter all reached the right number on screen, but two ended in French and one ended in English. Under sustained updates an in-place engine can fall behind and show the original language.
I kept all three in the write-up, with what replaced them. A study that hides its own corrections is asking you to trust the parts you cannot check.
What a repair costs the person reading
Once the node is detached there are two ways out. Put the original back and let the translator notice and retranslate it. Or write the new value into the wrapper the translator already built.
The first is what the existing libraries do. It works, and the reader pays for it. Five replicates of four updates, sampling the visible text every 50 milliseconds:
Restoring the node showed source-language text for 100 to 150 milliseconds per update, and 500 to 600 milliseconds across a sequence of four. Writing into the wrapper showed it for 0 milliseconds, in all twenty updates.
A price that changes once is a flash nobody reports. A counter pays it every tick.
That section used to say 150 to 200 milliseconds per update and 700 across the sequence. It came from a single run, and five replicates did not reproduce it. The report keeps all five now, because one run of a timing measurement is not a fact about anything.
Where the better approach stops working
Writing a new number into an already-translated sentence is fine in Dutch. In Russian it breaks the grammar.
Intl.PluralRules('ru') puts 4 in the few category and 7 in many, and the noun ending follows the category. So Здесь 4 лампочки! must not become Здесь 7 лампочки!. An early build of mine produced exactly that, quietly, in a language I do not read.
It now refuses whenever the plural category, the digit count or the shape of the sentence changes, or when it does not recognise the locale. On refusal the reader gets the correct number in the original language. That is a real loss, and it is the honest one: a number that is right in the wrong language beats a number that is wrong in grammar nobody on the team can check.
Dutch and German report other at every count. The failure would have stayed invisible in exactly the languages this was developed against.
What I still do not know
Safari is unmeasured. There has been no Windows build since 2012, and Playwright's WebKit ships without a translator. A real run needs a Mac and someone willing to click through a prompt. The row in the table is empty rather than guessed.
Failed recordings score null rather than false, so a run where translation never fired can never be read later as evidence that an engine is safe.
The library
The work produced a package called translate-shield. It links each detached node to the wrapper that replaced it and forwards React's writes there, so updates reach the screen in the language the reader chose. Zero dependencies, about 15 kB packed. On Edge and Firefox it does nothing, because there is nothing there to do.
It does not translate anything and it does not replace an i18n library.
There is a demo that runs the protected and unprotected versions side by side while your own browser translates them. It uses two separate documents, because the patch covers a whole document and a single page could not host an honest control.
Every number above traces to a JSON file in the repository, produced by a test you can re-run. Including the ones I had to correct.
Links to paste at the end
Live demo: https://google-translate-simulation.netlify.app/
Source and every raw recording: https://github.com/alievdavlat/translate-shield