The root cause wasn't slow iframe loading. It was too many tasks fighting over the main thread during page load.
React hydration, ad initialization, analytics scripts, thumbnail processing, game iframe initialization, WebGL startup — none of them looked slow on their own. But they all ran inside the same time window, and the combined cost was a 1.8-second white screen after the user clicked "Start."
The fix was priority scheduling + yielding the main thread. INP dropped from 480ms to 120ms, at the cost of about 18KB more first-load JS. Here's the full debugging process.
Environment
- Vue 3.4 + Vite 5.2
- Test devices: Moto G Power (Android 13 Chrome 125), iPhone 12 (iOS 17.4 Safari)
- Network: 4G, Chrome DevTools throttling (20ms RTT)
- Tools: Chrome DevTools Performance panel, Lighthouse 12
Symptoms
After users clicked the "Start Game" button on the game detail page, nothing happened for about 1.8 seconds. Then the game appeared. During that time, the button had no hover feedback and no click ripple.
I recorded the Performance panel in DevTools and found that after the click event fired, the main thread was occupied by a series of long tasks. INP measured 480ms, well above the "good" threshold (under 200ms).
First Misdiagnosis: I Thought the iframe Was Slow
My initial assumption was that <iframe src="game-entry.html"> took too long to load game assets when mounting.
So I made two changes:
- Added
loading="lazy"to the iframe - Delayed setting the
srcuntil after the click
Result: it got slower after the click. Because the game assets could have been loaded earlier, and I pushed that to after the click, the user had to wait through a full load cycle.
First attempt failed. The root cause wasn't the iframe's load timing.
How I Tracked It Down
Step 2: Recording the Full Timeline in the Performance Panel
I recorded a full "page load → click start → game appears" timeline again, and found the key information.
Within the 1.8 seconds after the click event fired, the main thread ran these tasks in sequence:
- React hydration cleanup: ~42ms
- Ad SDK initialization: ~38ms
- Analytics script execution: ~21ms
- Thumbnail processing (
createImageBitmap): ~47ms - Game SDK initialization: ~55ms
- WebGL context creation and shader compilation: ~63ms
None of these tasks looked catastrophic on its own, but they ran almost back-to-back on the timeline. The total was around 266ms, plus the browser's own rendering, style calculation, and layout overhead, which showed up as 480ms INP.
This matches what the OzoGames developer summarized from practice: performance problems usually aren't caused by one obviously bad script. They come from too many reasonable tasks trying to run at the same time. The browser doesn't care whether each individual task has a reason to exist. If enough work arrives on the main thread at once, players still feel a slow page.
Step 3: Understanding the Shared Nature of the Main Thread
There was a key shift in perspective here.
JavaScript execution, layout, style calculation, event handling, and many rendering tasks all depend on the browser's main thread. Imagine the page loading while doing all of these at once: React hydration, analytics initialization, ad initialization, consent management, recommendation rendering, thumbnail processing, game iframe initialization, game SDK initialization, WebGL startup.
On their own, none of these tasks may be catastrophic. The problem is that the browser receives all of them in the same short window, causing contention.
The way to think about browser performance shouldn't just be "how expensive is this script" — it should be "what else is happening when this script runs." Timing matters almost as much as size.
The Fix
Priority Model
Based on the diagnosis, I designed a five-level priority model:
- Priority 1: Critical rendering and interaction (page shell, button response)
- Priority 2: Business-critical initialization (user auth, core state)
- Priority 3: Visible secondary UI (thumbnails, recommendation areas)
- Priority 4: Non-critical analytics and enhancements (analytics scripts, consent management)
- Priority 5: Heavy features triggered by user intent (game runtime)
For a game page, the game runtime itself belongs to Priority 5 until the player presses Play.
Splitting Long Tasks with yield
The fix wasn't "delete tasks" — it was "move tasks," changing when they run.
The core technique is yield: periodically give the main thread back to the browser inside a long task, so higher-priority work like user input can run.
setTimeout can also yield, but it has a problem: the continuation after the yield goes to the end of the task queue. Any other task scheduled in the meantime — a third-party analytics callback, another component's task, another setTimeout — runs before you regain control. Your loop can get stuck behind work you don't care about, and a task that should finish in 100ms can stretch unpredictably.
scheduler.yield() solves this. It returns a Promise, execution pauses at the await, and the main thread is handed back. The key difference: the continuation goes into a priority queue. When the browser comes back, your function resumes before other similar waiting tasks, not after them.
In Chrome's framework, the continuation of scheduler.yield() has higher priority than same-level scheduler.postTask() tasks, which prevents your loop from starving.
The rewritten loop barely changes:
async function processThumbnails(thumbnails) {
for (const [i, thumb] of thumbnails.entries()) {
await createImageBitmap(thumb)
if (i % 10 === 0) {
await scheduler.yield() // yield every 10 thumbnails
}
}
}
Concrete Scheduling Changes
Defer ad and analytics scripts until after the page is interactive.
<head>
<script src="/app.js"></script>
</head>
<body>
<script>
const loadThirdParty = () => {
const scripts = [
'https://analytics.example.com/tracker.js',
'https://ads.example.com/loader.js',
]
scripts.forEach(src => {
const s = document.createElement('script')
s.src = src
s.async = true
document.body.appendChild(s)
})
}
['click', 'scroll', 'keydown'].forEach(evt =>
addEventListener(evt, loadThirdParty, { once: true })
)
setTimeout(loadThirdParty, 3000)
</script>
</body>
Process thumbnails in chunks. Instead of processing all thumbnails at once, yield every 10.
Delay game iframe initialization until after the user clicks. This was already implemented with the facade pattern in the previous article. The key point: game runtime initialization (WebGL context creation, shader compilation) is moved to Priority 5 and only triggered after the user clicks.
Safari Compatibility
scheduler.yield() is stable in Chrome and Edge (129+, September 2024), covering about 70% of global traffic, but Safari and Firefox don't support it yet. A feature detection and fallback is needed:
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield()
}
return new Promise((resolve) => setTimeout(resolve, 0))
}
The fallback uses setTimeout(0). The continuation still goes to the end of the queue, but at least it doesn't throw.
Verification Data
| Metric | Before | After |
|---|---|---|
| INP (click start) | 480ms | 120ms |
| First Contentful Paint (FCP) | 1.4s | 1.4s (unchanged) |
| Largest Contentful Paint (LCP) | 2.1s | 2.1s (unchanged) |
| First-load JS (gzip) | 85KB | 103KB |
| Main thread long tasks (2s after click) | 6 | 2 |
Test conditions: Moto G Power, Chrome 125, 4G throttling (20ms RTT), 10 runs each, median.
Note: FCP and LCP didn't change, because those metrics measure when page content first appears, and the post-click performance problem doesn't affect them. INP is the correct metric for interaction responsiveness.
Trade-offs
First-load JS grew by about 18KB. Because of the scheduler.yield() compatibility code and task scheduling logic. This is a conscious trade-off: 18KB for INP dropping from 480ms to 120ms.
Task scheduling complexity increased. Tasks that used to run synchronously now need to be split and queued, reducing code readability. Every task that needs to yield has to consider "where to yield" and "whether the continuation after yielding is safe."
Safari isn't fully optimized. The fallback uses setTimeout(0), and the continuation goes to the end of the task queue. The improvement on Safari is smaller than on Chrome. Measured Safari INP is about 220ms, worse than Chrome's 120ms, but much better than 480ms before the optimization.
Unresolved
- Occasional white screen on iOS Safari: On low-end iPhones, game iframe initialization still occasionally produces a white screen. Currently falls back to retrying up to 3 times after the click.
- Precompiling WebGL shaders: If there were a way to precompile shaders before the user clicks without creating a rendering context, the post-click delay could be reduced further. No reliable cross-browser solution found yet.
- Third-party script main thread usage can't be fully controlled: The initialization timing of analytics scripts and ad SDKs can be deferred, but their main thread usage during execution can't be split, because it's third-party code.
References
- Chrome for Developers'
scheduler.yield()documentation explains the mechanism and continuation priority in detail - DEV community articles on INP optimization show the problems with
setTimeoutyielding and the improvements inscheduler.yield() - The OzoGames developer's practical summary provides the "main thread is a shared resource" and five-level priority model framework
Top comments (0)