Why book.md’s tweet embeds were crashing Safari on iPhone
Why book.md’s tweet embeds were crashing Safari on iPhone
A step-by-step account of the bug and the fix, for book.md’s “Illustrations sneak peeks” section.
1. The starting point: collapsed sections
book.md has several <details>/<summary> blocks like this:
<p><details>
<summary>Load tweets (may take a few seconds)</summary>
<blockquote class="twitter-tweet" ...>...</blockquote>
<blockquote class="twitter-tweet" ...>...</blockquote>
...
</details></p>
<details> is a native, collapsible HTML element. Collapsed by default, it only shows the <summary> line (“Load tweets…”) until a reader clicks it open. The “may take a few seconds” text was already a hint that these were meant to load lazily.
Across the whole page there are 65 of these <blockquote class="twitter-tweet"> elements, spread over several sections going back to 2022.
Important detail: <details> being collapsed only changes rendering. The blockquotes are still fully present in the DOM, a script that walks document can find and touch them whether the section is open or not.
2. The original loader, and why it crashed
The page also had one line:
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
Twitter/X’s widgets.js has a built-in default behaviour: the moment it finishes loading, it scans the entire document for every element with class="twitter-tweet" and synchronously converts each one into a rendered embed (iframe). This scan is unconditional, it doesn’t care whether an element is visually hidden inside a collapsed <details>.
So on every page load:
- The script downloads in the background (
async). - As soon as it’s ready, it fires a
requestAnimationFramecallback. - Inside that single callback, it processes all 65 blockquotes, one after another, with nothing yielding control back to the browser in between.
Using Safari’s Web Inspector (Develop menu → device → tab → Timelines), this showed up as a single frame (“Animation Frame 2 Fired”) that took 3.01 seconds, almost entirely “Script” time, made up of a long chain of (program) evaluations at near-identical durations, one per tweet.
Three seconds of a fully blocked main thread is much more consequential on an iPhone than on a desktop Mac: iOS Safari is far more aggressive about killing an unresponsive WebContent process. That kill is what showed up to the user as “the page crashed.”
3. First attempt: load on click (didn’t fully work)
The obvious fix looked like: don’t load widgets.js until a reader actually opens a section, and only render that section’s tweets.
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('details').forEach(function (details) {
details.addEventListener('toggle', function onOpen() {
if (!details.open) return;
details.removeEventListener('toggle', onOpen);
if (window.twttr && twttr.widgets) {
twttr.widgets.load(details);
} else {
var s = document.createElement('script');
s.src = 'https://platform.twitter.com/widgets.js';
s.charset = 'utf-8';
s.onload = function () { twttr.widgets.load(details); };
document.head.appendChild(s);
}
});
});
});
</script>
The assumption was that twttr.widgets.load(details) would scope the render to just the opened <details>. That assumption was wrong for the first call: on the very first time widgets.js executes, it still runs its own built-in full-document scan, completely independent of whatever container you pass to .load(). Since all 65 blockquotes still carried class="twitter-tweet" (just hidden, not removed), opening any single section for the first time triggered the exact same 65-tweet synchronous storm as before, just delayed from page-load time to click time. That’s the “it explodes when I load tweets” behaviour.
4. The actual fix: hide tweets from the scan, then unmask on demand
The built-in full-document scan can’t be turned off, but it can be starved: it only finds elements that currently carry class="twitter-tweet". So the fix has two parts.
Part A — rename the class on all 65 blockquotes in book.md to something widgets.js doesn’t recognise:
<blockquote class="tweet-embed" data-dnt="true" data-theme="dark">...</blockquote>
Now, whenever widgets.js runs its automatic scan, it finds nothing, because nothing on the page carries the real twitter-tweet class by default.
Part B — when a reader opens a <details>, relabel only that section’s blockquotes back to the real class, immediately before asking Twitter’s script to render:
details.querySelectorAll('.tweet-embed').forEach(function (bq) {
bq.classList.add('twitter-tweet');
});
At that point, whether it’s widgets.js’s own first-load auto-scan or an explicit twttr.widgets.load(details) call, the only elements it can possibly find are the handful of tweets inside the section that was just opened.
5. Polish: a proper “load once” script loader
The first click-to-load draft dynamically created a <script> tag and guarded it with a hand-rolled boolean (scriptRequested) to stop it being inserted twice. That works, but it’s reinventing something Twitter already ships: an official async-loader snippet that guarantees a single script insertion and lets callers queue work until the script is ready.
window.twttr = (function (d, s, id) {
var t = window.twttr || {};
if (d.getElementById(id)) return t; // already inserted? stop here.
var js = d.createElement(s);
js.id = id; // 'twitter-wjs'
js.src = 'https://platform.twitter.com/widgets.js';
js.charset = 'utf-8';
d.head.appendChild(js);
t._e = [];
t.ready = function (f) { t._e.push(f); }; // queue callback for later
return t;
}(document, 'script', 'twitter-wjs'));
This block sits at the top level of the page’s markup, not inside any click handler, so it runs exactly once, at page parse time, the same as any other <script> tag. The d.getElementById(id) check is a second line of defence: if this code ever ran again for any reason, it would see the id="twitter-wjs" tag already in the document and bail out instead of inserting a duplicate.
Calling twttr.ready(fn) never touches the DOM, it only ever pushes fn onto the _e array. So opening ten sections queues ten callbacks; it can never cause ten downloads.
Once the real widgets.js file finishes loading, it does two things as part of its own initialisation: it runs every callback queued in window.twttr._e, and it replaces twttr.ready with a version that calls its argument immediately (since there’s no longer anything to wait for). So sections opened before the script has finished loading get queued and run once it arrives; sections opened afterwards run their callback straight away. Either way, everything routes through that one script instance.
6. The final book.md snippet
<script>
window.twttr = (function (d, s, id) {
var t = window.twttr || {};
if (d.getElementById(id)) return t;
var js = d.createElement(s);
js.id = id;
js.src = 'https://platform.twitter.com/widgets.js';
js.charset = 'utf-8';
d.head.appendChild(js);
t._e = [];
t.ready = function (f) { t._e.push(f); };
return t;
}(document, 'script', 'twitter-wjs'));
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('details').forEach(function (details) {
details.addEventListener('toggle', function onOpen() {
if (!details.open) return;
details.removeEventListener('toggle', onOpen);
details.querySelectorAll('.tweet-embed').forEach(function (bq) {
bq.classList.add('twitter-tweet');
});
twttr.ready(function (t) { t.widgets.load(details); });
});
});
});
</script>
paired with every embed in the markdown now written as <blockquote class="tweet-embed" ...> instead of <blockquote class="twitter-tweet" ...>.
7. What happens now, in order
- Page loads. No
widgets.jsrequest is made yet, and no tweet is visible or rendered, all blockquotes carry the inerttweet-embedclass. - Reader clicks a “Load tweets” summary, opening one
<details>. - The
togglelistener fires once (it removes itself immediately after, so re-closing and reopening the same section does nothing further). - Only that section’s blockquotes get
class="twitter-tweet"added. twttr.ready(...)either queues a callback (first time, script still downloading) or runs it immediately (script already loaded from an earlier section).widgets.jsrenders only the blockquotes it can currently see, the handful in the opened section, never the other 64.- Opening a second, third, … section repeats steps 3–6, each one scoped to itself, and each one reusing the single already-loaded script.
Net result: nothing costly happens until a reader opts in, and opting in only ever costs the size of the section they opened.