DEV Community

Cover image for I built an unblocked games site with 250 games on my own CDN, here's what broke
ShawGames
ShawGames

Posted on

I built an unblocked games site with 250 games on my own CDN, here's what broke

The architecture

I recently built ShawGames, a browser-based gaming site with more than 250 games.

At first, the architecture looked simple:

  • Build the frontend with Next.js
  • Export it as a static site
  • Store large game files in Cloudflare R2
  • Serve everything through a custom CDN domain
  • Deploy the frontend separately

In practice, the frontend was the easy part.

The difficult part was serving hundreds of games reliably when every game had a different file structure, different asset-loading behavior, and different assumptions about where its files were hosted.

This is the technical story of what worked, what broke, and what I learned.

The architecture

The site is based on a statically exported Next.js frontend.

Instead of running a persistent Node.js server, I generate static HTML, JavaScript, CSS, and JSON files during the build process.

The basic flow looks like this:

Next.js application
        |
        | next build + static export
        v
Static frontend hosting
        |
        | game metadata and launch URLs
        v
Cloudflare R2
        |
        | custom CDN domain
        v
Game files, assets, WebAssembly, audio, and textures
Enter fullscreen mode Exit fullscreen mode

The frontend contains the catalogue, search interface, categories, game pages, and navigation.

The actual game files are stored separately in Cloudflare R2.

This separation matters because some games are only a few megabytes, while others include large WebAssembly binaries, texture files, audio files, or Unity build artifacts.

Putting all of that inside the main frontend deployment would make builds slower, deployments heavier, and asset management much harder.

Why I used a static Next.js export

A gaming catalogue is a good fit for static generation.

Most pages do not require server-side rendering on every request. A game page mainly needs:

  • A title
  • A description
  • A thumbnail
  • A category
  • A launch URL
  • Some related-game data

That information can be generated at build time.

A static export also gave me a few useful properties:

  • No application server to maintain
  • Fewer runtime failure points
  • Easy deployment to edge hosting
  • Fast delivery of HTML and JavaScript
  • Lower infrastructure complexity

The trade-off is that anything dynamic has to be handled in the browser or through external services.

It also means asset paths must be correct before deployment. A server-rendered application can sometimes rewrite or proxy broken paths at runtime. A static export cannot. If the generated URL is wrong, the browser simply requests the wrong file.

That became important later.

Why the games live in Cloudflare R2

The game library contains many different kinds of browser games.

A simple HTML5 game might look like this:

game/
├── index.html
├── game.js
├── style.css
└── assets/
Enter fullscreen mode Exit fullscreen mode

A Unity WebGL game might look more like this:

game/
├── index.html
├── Build/
│   ├── game.loader.js
│   ├── game.framework.js.unityweb
│   ├── game.data.unityweb
│   └── game.wasm.unityweb
└── TemplateData/
Enter fullscreen mode Exit fullscreen mode

Other games dynamically load files from nested folders, construct asset URLs at runtime, or expect to run from the root of a domain.

R2 gave me object storage without forcing those files into the frontend repository.

Each game could be uploaded as its own directory:

/games/game-one/index.html
/games/game-one/assets/texture.png
/games/game-two/index.html
/games/game-two/build/game.wasm
Enter fullscreen mode Exit fullscreen mode

The frontend then launches the relevant index.html file through the CDN domain.

Conceptually, it sounds straightforward.

The main problem is that browser games are not always portable.

Serving games from R2 was the hardest part

The biggest technical challenge was not uploading files. It was preserving the exact path structure each game expected.

Consider this code inside a game:

fetch("./assets/level-1.json");
Enter fullscreen mode Exit fullscreen mode

If the game is loaded from:

https://cdn.example.com/games/my-game/index.html
Enter fullscreen mode Exit fullscreen mode

the browser should request:

https://cdn.example.com/games/my-game/assets/level-1.json
Enter fullscreen mode Exit fullscreen mode

That works as long as the document URL, base path, redirects, and object paths all remain consistent.

But games often contain assumptions such as:

fetch("/assets/level-1.json");
Enter fullscreen mode Exit fullscreen mode

That leading slash changes everything. The browser now requests:

https://cdn.example.com/assets/level-1.json
Enter fullscreen mode Exit fullscreen mode

instead of:

https://cdn.example.com/games/my-game/assets/level-1.json
Enter fullscreen mode Exit fullscreen mode

The game may work perfectly on the original publisher’s domain but fail when moved into a subdirectory.

Other common problems included:

  • Case-sensitive file names
  • Hardcoded asset domains
  • Missing MIME types
  • Incorrect compression headers
  • WebAssembly loading failures
  • Nested paths that were not uploaded
  • Files referenced dynamically but absent from the initial build
  • Service workers caching outdated files
  • Cross-origin restrictions
  • Games expecting same-origin storage or cookies

A single missing texture might only cause a visual glitch.

A missing JavaScript bundle, .wasm file, or game data file can prevent the game from starting entirely.

Why third-party embeds get blocked

One early approach was to embed games directly from third-party websites.

Technically, that is often as simple as:

<iframe
  src="https://third-party-game-site.example/game"
  width="100%"
  height="100%"
></iframe>
Enter fullscreen mode Exit fullscreen mode

The problem is that the game is still being loaded from the third-party domain.

The page containing the iframe may be allowed, while the iframe source is blocked by a school or organizational filtering system.

Filters can evaluate more than the domain shown in the browser’s address bar. They may inspect:

  • The iframe source
  • DNS requests
  • Network destinations
  • URL categories
  • Page reputation
  • Content classification
  • Known gaming or entertainment domains
  • Requests made by scripts after the page loads

This creates an important distinction:

Main site domain: allowed
Embedded game domain: blocked
Enter fullscreen mode Exit fullscreen mode

From the user’s perspective, the site opens but the game frame remains blank or displays an error.

Third-party embedding also creates operational risk. The external provider can change a URL, add iframe restrictions, remove the game, introduce advertising, or block embedding without notice.

Hosting permitted game assets directly gave me more control over availability, performance, and file paths. It did not eliminate network filtering, but it removed the dependency on dozens of unrelated third-party game hosts.

The asset outage

The most serious recent issue was an asset outage caused by caching and CDN behavior.

The frontend was still online.

Game pages still loaded.

The catalogue and thumbnails appeared normal.

But some game assets failed to load from the CDN, which meant affected games either froze during startup, displayed a loading screen indefinitely, or opened with missing resources.

This was difficult to diagnose because it did not look like a full outage.

The application shell worked, and requests sometimes behaved differently depending on whether the asset had already been cached.

That created inconsistent reports:

  • A game worked for one user
  • The same game failed for another
  • A refreshed page sometimes behaved differently
  • Newly uploaded files did not always appear immediately
  • Some requests returned stale responses

The failure was in the asset-delivery layer rather than the Next.js application itself.

Architecture diagram showing a static Next.js frontend sending game requests through a custom CDN to Cloudflare R2 object storage

Why CDN caching made debugging harder

Caching is normally a performance feature.

For static game assets, it is especially valuable because large JavaScript bundles, textures, audio files, and WebAssembly binaries do not need to be fetched from object storage on every request.

A simplified request flow looks like this:

Browser
   |
   v
Cloudflare edge cache
   |
   | cache miss
   v
R2 object storage
Enter fullscreen mode Exit fullscreen mode

When the cache contains a valid object, the response is fast.

The problem appears when the cache contains an outdated or incorrect response.

For example:

  1. An asset URL is requested before the file exists.
  2. The CDN caches the error response.
  3. The missing file is uploaded to R2.
  4. The browser requests the same URL again.
  5. The edge still serves the cached error.

The origin is now correct, but the user continues to receive the old result.

A similar issue can happen when replacing a file without changing its URL.

/game/build/game.data
Enter fullscreen mode Exit fullscreen mode

If that URL is cached aggressively, uploading a new version does not guarantee that every edge location immediately serves the new object.

This is why immutable asset naming is so useful.

Instead of:

game.js
Enter fullscreen mode Exit fullscreen mode

use something like:

game.8f31c2.js
Enter fullscreen mode Exit fullscreen mode

When the contents change, the URL changes as well. The CDN treats it as a new object rather than reusing a stale cached response.

Many third-party games are not built with this deployment model in mind, so their asset names cannot always be changed safely.

How I fixed it

The fix involved treating cache state as part of the deployment process rather than assuming the CDN would immediately reflect the contents of R2.

The main improvements were:

1. Verifying assets directly at the origin layer

I checked whether affected files actually existed in R2 and whether their object keys matched the requested URLs exactly.

This helped separate storage problems from cache problems.

2. Purging stale cached responses

Cached errors and outdated assets needed to be invalidated so edge locations would request the current object again.

Without this step, fixing the object in storage was not always enough.

3. Auditing cache rules

Not every response should have the same cache policy.

Long-lived caching is useful for versioned static assets.

It is less appropriate for error responses, frequently replaced files, or launch documents such as index.html.

A safer pattern is:

Versioned game assets:
Cache aggressively

Game index files:
Use shorter caching

Error responses:
Avoid long cache lifetimes
Enter fullscreen mode Exit fullscreen mode

4. Testing from a clean browser session

A normal refresh is not always enough because the browser may have its own cached copy.

Testing needed to include:

  • Incognito sessions
  • Disabled browser cache
  • Direct asset requests
  • Multiple devices or networks
  • Cache-status response headers
  • Requests with temporary cache-busting query parameters

5. Adding post-upload validation

Uploading a game is not the same as confirming it works.

A better deployment process checks the launch document and critical assets after upload.

For example:

const criticalFiles = [
  "index.html",
  "game.js",
  "build/game.wasm",
  "build/game.data"
];

for (const file of criticalFiles) {
  const response = await fetch(`${baseUrl}/${file}`);

  if (!response.ok) {
    throw new Error(`${file} returned ${response.status}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The exact critical files differ by game, but even a basic validation step catches many failures before users do.

Performance after the fix

Once the CDN and cache behavior were corrected, game loading became noticeably faster and more consistent.

The improvement came from several layers working together:

  • Static Next.js pages load quickly
  • Game assets are geographically cached
  • Large files are served separately from the frontend
  • Repeat requests can be fulfilled from the edge
  • The main application deployment remains small

The biggest benefit was not just raw speed.

It was predictability.

A fast game that randomly fails is worse than a slightly slower game that loads every time.

What I would do differently

If I were rebuilding the asset pipeline from the beginning, I would make validation and cache management first-class parts of the system.

Normalize every game before upload

Each game should pass through an ingestion process that checks:

  • Whether index.html exists
  • Whether referenced local assets exist
  • Whether paths are relative or absolute
  • Whether external domains are required
  • Whether the game uses WebAssembly
  • Whether required MIME types are supported
  • Whether filenames differ only by case
  • Whether service workers are present

Maintain a game manifest

Each game could have a manifest such as:

{
  "slug": "example-game",
  "entry": "index.html",
  "engine": "unity-webgl",
  "criticalAssets": [
    "Build/game.loader.js",
    "Build/game.data.unityweb",
    "Build/game.wasm.unityweb"
  ],
  "requiresExternalNetwork": false
}
Enter fullscreen mode Exit fullscreen mode

This would make automated testing much easier.

Separate deployments from cache invalidation

A complete deployment should include:

Upload files
    |
Validate object keys
    |
Test critical URLs
    |
Invalidate affected cache entries
    |
Run launch check
    |
Mark game as available
Enter fullscreen mode Exit fullscreen mode

A game should not appear publicly until every stage succeeds.

Add synthetic monitoring

A scheduled checker could request a sample of games and verify that:

  • The launch page returns 200
  • Critical JavaScript loads
  • WebAssembly assets are accessible
  • Response content types are correct
  • CDN responses are not stale errors

This would detect partial asset outages before they affect a large number of sessions.

The main lesson

Building a site with hundreds of browser games is not primarily a frontend challenge.

The frontend can be statically generated and deployed easily.

The real complexity is in asset portability and delivery:

  • Every game has different assumptions
  • Relative paths matter
  • CDN caches can preserve failures
  • Browser caches can hide fixes
  • Third-party embeds introduce external dependencies
  • A successful upload does not guarantee a successful launch

Cloudflare R2 and a static Next.js export turned out to be a good combination, but only after I treated the CDN as an active part of the system rather than a transparent layer in front of storage.

The recent outage reinforced one rule I now consider essential:

Never consider an asset deployment complete until it has been tested through the same public CDN URL that users will request.

The current version is live at shawgames.com, and the next step is making the ingestion and validation pipeline as reliable as the frontend itself.

Have you dealt with stale CDN responses, cached 404s, or browser games that broke after being moved to a different host? I would be interested to hear how you handled it.

Top comments (1)

Collapse
 
shawgames_320899600986f8d profile image
ShawGames

Thanks for reading! The hardest part was managing asset paths and stale CDN responses across hundreds of different game builds. I would be interested to know how others handle this, whether through versioned filenames, automated checks, or cache purges.