This series covered six articles, each focused on a specific technical problem. But when I sat down to write the retrospective, I realized the most valuable thing wasn't "how each problem was solved" — it was what I based each decision on, and which ones held up in hindsight.
Here's the timeline of decisions, with the data I had at the time and my current judgment. At the end, four unresolved pits.
Demo from https://www.playfiddlebops.com/
Decision 1: Start with Zero Backend — games.json + localStorage
The call at the time: MVP doesn't need a database. Static JSON for the game list, localStorage for scores.
Outcome: The decision was right, but with one condition — users won't ask "why can't I see my phone scores on my computer?" Once someone asks that, the zero-backend window is over.
Looking back: The games.json structure (id, title, slug, thumbnail, entry, category) carried through to the backend phase, which means the data model was sound. localStorage lasted about three months, until cross-device sync became a real need.
Lesson: Zero backend doesn't mean "never use a backend." It means "introduce a backend as late as possible." The signal is simple — when users start asking why data doesn't sync, it's time.
Decision 2: Choose Next.js SSG Over Vue + Vite
The call at the time: SEO was a hard constraint. Next.js SSG works out of the box; Vue needs an extra prerender plugin.
Outcome: The decision was right, but for a slightly different reason than I thought.
In the second article I documented the full comparison. Next.js first-load JS was ~85KB, Vue ~50KB. Vue built twice as fast. But Vue's LCP after prerendering was actually slower (1.5s vs 1.2s), because the prerendered HTML still included the full Vue runtime, with higher hydration cost.
The real deciding factor wasn't "whose first-load JS is smaller," but "who makes the facade pattern easier to implement." On day three, when I recorded the full timeline in the Performance panel, I found that the framework choice's impact on first-load performance was far smaller than the iframe initialization strategy's impact. Vue was ultimately abandoned, not because it was slower, but because SEO required extra plugins and the dynamic route prerendering scalability was worse than Next.js's SSG system.
Lesson: In tech selection, don't compare "which is better." Compare "which makes your key constraint easier to implement." My key constraint was the facade pattern, not first-load JS size.
Decision 3: Use the Facade Pattern to Delay iframe Initialization
The call at the time: The game iframe is the heaviest component on the page. It shouldn't initialize on page load.
Outcome: This was the most valuable decision in the entire series.
In the third article I documented the full debugging process. Users waited 1.8 seconds after clicking "Start." I initially thought the iframe was slow to load, made the wrong optimization (added loading="lazy", delayed setting src), and it got slower after the click.
Recording the full timeline in the Performance panel revealed the root cause: multiple tasks running back-to-back on the main thread. React hydration cleanup, ad SDK initialization, analytics scripts, thumbnail processing, game SDK initialization, WebGL context creation. Total about 266ms, plus browser overhead, showing up as 480ms INP.
The fix was priority scheduling + yield. INP dropped from 480ms to 120ms, at the cost of 18KB more first-load JS.
One detail worth calling out: the difference between scheduler.yield() and setTimeout. setTimeout puts the continuation at the end of the task queue, where it can get stuck behind unimportant tasks. scheduler.yield() puts the continuation in a priority queue, resuming before other same-level tasks. It's available in Chrome 129+, with fallbacks needed for Safari and Firefox.
Lesson: Performance optimization isn't "make code run faster." It's "make the right code run at the right time." Deleting tasks is often unrealistic. Moving when they run is the key.
Decision 4: iframe sandbox with a Separate Domain + Least Privilege
The call at the time: Third-party games are untrusted. They must be isolated with sandbox.
Outcome: The decision was right, but there was a near-miss.
In the fourth article I documented item-by-item sandbox attribute testing. The most critical finding: when the embedded page is same-origin with the parent page, setting both allow-scripts and allow-same-origin allows the embedded page to simply remove the sandbox attribute and reload itself, effectively breaking out of the sandbox entirely.
This means if I had deployed games under a same-origin path, sandbox would have been nearly useless. The solution was to deploy games on a separate domain. Even with allow-same-origin enabled, game scripts can only access their own origin.
The final sandbox configuration was allow-scripts allow-same-origin allow-forms allow-popups, plus allow for fullscreen, autoplay, gamepad, and pointer lock.
Cost: One game relied on allow-modals to show native dialogs. After being blocked by sandbox, it was changed to an HTML overlay. This is a conscious trade-off — security over convenience.
Lesson: sandbox security depends on whether the game runs on a separate domain. Same-origin + allow-same-origin is an easy-to-miss security hole.
Decision 5: Use Combined JSON-LD Types for Rich Results
The call at the time: VideoGame type alone should be enough for a game page.
Outcome: It wasn't. Google's official documentation clearly states: for software applications, Google won't show richer search results for structured data that only contains the VideoGame type. You need to combine it with other available node types, like MobileApplication.
The fixed JSON-LD:
{
"@context": "https://schema.org",
"@type": ["VideoGame", "MobileApplication"],
"name": "Typing Hero",
"applicationCategory": "Game",
"operatingSystem": "Web",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.2",
"ratingCount": "128"
},
"gamePlatform": ["Web Browser", "Mobile"]
}
Another key finding: Game page SEO content strategy differs from a regular blog. A SitePoint forum discussion put it well — users searching for a game page have the intent "I want to play this game." Stacking 1,500 words above the game actually makes the page worse. The right approach is to add short controls, tips, and FAQ below the game. Content that satisfies search intent is enough; there's no word count target.
Lesson: Game page SEO is "help search engines understand what the page is about," not "stack content volume." Combined JSON-LD types and short structured content are more effective than long articles.
Decision 6: SQLite + Single-Writer Funnel
The call at the time: SQLite is zero ops and enough for a personal project.
Outcome: SQLite was enough, but only if you understand its concurrency model.
In the sixth article I documented three traps: direct UPDATE overwriting scores, SQLITE_BUSY lock errors, and unstable same-score ranking.
The key fixes were enabling WAL mode (read/write concurrency, writers don't block readers) and using a single-writer funnel (Promise chain serializing all writes). Load test data went from 97% success at 100 concurrency to 100%, and P95 response time from 180ms to 45ms.
But: Serializing writes means throughput has a ceiling. In a local environment, a single writer with SQLite handles roughly 800–1200 writes/second. If the game site grows to thousands of submissions per second, SQLite isn't enough.
Lesson: SQLite's problem was never "performance." It's "whether you serialized writes correctly." In a single-writer model, 800–1200 writes/second is far more than enough for a personal project.
Performance Budget
Based on the series' measured data, here's my current performance budget:
| Metric | Target | Measured |
|---|---|---|
| LCP (4G) | ≤ 2.5s | 1.2s (static pages) / 2.1s (game detail pages) |
| INP (click start) | ≤ 200ms | 120ms (Chrome) / 220ms (Safari) |
| CLS | ≤ 0.1 | 0.04 |
| First-load JS (gzip) | ≤ 150KB | 103KB |
| Main thread long tasks (2s after click) | ≤ 3 | 2 (was 6 before optimization) |
Core Web Vitals thresholds are LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1, measured at the 75th percentile. INP replaced FID in March 2024 and measures the full time from input to next paint, not just first interaction.
One easy-to-miss point: Core Web Vitals ranking signals are binary — 199ms passes, 201ms fails. So the performance budget needs headroom, not a target right at the threshold.
SEO Checklist
| Item | Status | Notes |
|---|---|---|
| URL structure /games/{category}/{slug} | Done | Semantic, category and game identifier in URL |
| JSON-LD VideoGame + MobileApplication | Done | Combined types needed for rich results |
| generateMetadata server-side rendering | Done | Client-side dynamic metadata isn't crawlable |
| canonical pointing to source site | Done | dev.to manual, Medium via Import |
| robots.txt disallow /play/ | Done | Game runtime page is an iframe container, no independent content value |
| sitemap auto-generated | Done | Game pages weekly, homepage daily |
| CLS optimization (aspect-ratio reservation) | Done | CLS dropped from 0.32 to 0.04 |
| Game page content (controls + tips + FAQ) | Partial | Full content only for top 20% of games by traffic |
| hreflang | Not done | Currently only Chinese and English, no multilingual versions |
An important lesson from OYNAVA's retrospective: When all locale URLs return identical content, emitting hreflang for 8 languages makes Google discover 8x duplicate URLs and waste crawl budget. Removing hreflang and keeping only canonical lets crawlers refocus on real pages. Before doing multilingual, I'll confirm each locale's content is actually different before emitting hreflang.
Backend Evolution Path
| Stage | User count | Write frequency | Solution |
|---|---|---|---|
| Static JSON | < 20 games | None | Zero backend, local score storage |
| SQLite + Node.js | < 1000 users | < 500/sec | WAL mode + single-writer funnel |
| PostgreSQL + Redis | > 5000 users | > 1000/sec | Redis real-time ranking, PostgreSQL persistence |
| Headless CMS | Content management needs | — | Non-technical people edit game list |
The key judgment isn't "which technology is more advanced," but whether user count and write frequency have exceeded the current solution's comfort zone. SQLite's single-writer constraint means it stops being suitable when "multiple app servers access the same database simultaneously" — when you have a background worker writing at the same time as the web server, or multiple users modifying shared data at once, that constraint becomes a bottleneck.
4 Unfilled Pits
Pit 1: Anti-Cheat
Client-submitted scores have no server-side validation. Users can forge requests to submit arbitrary scores. This is the biggest security gap right now. Needs server-side score sanity checks, or moving game logic to the server.
Pit 2: iOS Safari WebGL Memory Issue
One WebGL game crashes to a white screen after about 5 minutes on iPhone. WebKit's bug tracker shows iOS Safari has an iframe WebGL memory leak issue. Currently falls back to limiting game session length and prompting the user to refresh.
Pit 3: Multilingual SEO
Currently only Chinese and English versions, no hreflang. If going international, each language version needs to have genuinely different content before emitting hreflang, otherwise it wastes crawl budget like OYNAVA's case.
Pit 4: Build Bottleneck as Game Count Grows
generateStaticParams pre-generates all game pages at build time. If the game count reaches 200+, build time increases significantly. Needs ISR or a Headless CMS, but that's no longer zero-backend.
The Biggest Surprise
After writing six articles, I realized the biggest surprise was that framework choice mattered far less than task scheduling.
In the second article, I agonized between Next.js and Vue for three days. In the third article, I found the real performance bottleneck was main thread task contention, almost unrelated to the framework. The same facade pattern works in both frameworks. The same priority scheduling works in both.
Tech selection decisions look important, but what really determines user experience is whether you understand the browser's resource model — the main thread is a shared resource, and timing matters almost as much as size.
References
- Google Search Central documentation on SoftwareApplication structured data, explaining that VideoGame type needs to be combined to get rich results
- web.dev guide on Core Web Vitals thresholds, LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1
- SitePoint forum discussion on HTML5 game page SEO, game page content strategy should match search intent rather than blog standards
- OYNAVA developer retrospective, covering SSR cost traps, N+1 requests, and hreflang duplicate content lessons
- Spielcade developer retrospective, covering technical SEO challenges across 17,000+ game pages and the design thinking behind the "Surprise Me" feature
Top comments (0)