Shipping a 28MB Unity WebGL Build on the Open Web
One shared Unity build, thirty megabytes, and a bandwidth ceiling no amount of caching config in the web repository can move.
arthis.land is a city of bricks. Click one and you land in a room — a 3D space you can walk around, rendered by Unity compiled to WebGL. The wall itself is disciplined about weight: a 100 kB gzipped critical path budget, enforced by a build guard that fails the build if React plus the app bundle drift over it. The room viewer is the opposite of disciplined. It is 28 to 30 MB compressed, and it is the single largest fact about this site.
That number is not a failure of tuning. It is what a Unity WebGL player costs once you have a renderer, a physics module, a scene loader and the IL2CPP-generated runtime in one WASM blob. The interesting question is not how to make it small — it is what happens to a project when one asset is two hundred times heavier than everything else combined, and how much of that you can actually influence from the web side. The honest answer is: very little. This guide is mostly about being precise regarding which levers exist and which are theatre.
One build, every room
Every room on the site is served by the same Unity build. There is no per-room compilation step, no build pipeline that bakes a scene into a bespoke player. The build is dropped into city/viewer/ as an opaque artefact and is never rebuilt inside the web repository at all. Rooms are data: a folder of geometry the shared player fetches at runtime.
This is the only arrangement that survives contact with growth. A per-room build would mean a 28 MB artefact per room, a Unity compile in the deploy pipeline, and a cold cache for every single visitor moving between rooms. With one shared build, the expensive bytes are fetched once per browser and every subsequent room costs only its geometry — 2.3 to 3.4 MB of .glb. The second room a visitor opens is roughly a hundredth the cost of the first. That ratio is the entire economic argument, and it is why the build being dropped in wholesale rather than integrated is a feature and not laziness.
It also means the room author never touches the viewer. Adding a room is adding a folder, which is the second design decision worth explaining.
The room-folder convention
A room is a directory under the viewer's streaming assets. The folder name becomes the URL. The trailing number in the .glb filename decides which brick on the wall the room lands on.
city/viewer/StreamingAssets/Rooms/
glass-atrium/
atrium2x5.glb <- 2x5 brick, 450px wide on the wall
thumb.webp
room.json <- optional
paper-works/
paperworks3x3.glb
thumb.webp
-> /city/glass-atrium
-> /city/paper-works
The build regenerates rooms.config.js by scanning these folders, so the filesystem is the source of truth and there is no registry to forget to update. A brick is 90px per size unit, so 2x5 is a 450px-wide brick on the wall; the geometry filename and the wall layout are the same declaration made once. If you want the geometry itself to fit that budget, getting a room .glb under 3MB is a separate discipline with its own trade-offs.
The arithmetic that sets the ceiling
Here is what one visitor costs, broken down by component, for a cold visit to one room:
| Component | Over the wire | Reused across rooms? |
|---|---|---|
Unity WebGL build (.unityweb data, WASM, framework, loader) | 28–30 MB | Yes — fetched once per browser |
Room geometry (.glb) | 2.3–3.4 MB | No — one per room |
| Wall HTML + JS bundle | ~81 kB gz | Yes |
| Brick snapshot JSON | tens of kB gz | Yes, immutable and fingerprinted |
Room thumbnail (thumb.webp) | low tens of kB | Per room, fingerprinted |
The wall bundle is 0.4% of a cold room visit. Every performance technique in the React application — transforms written straight to the DOM through refs, the pan/zoom commit gate, viewport culling of bricks, the lazily imported Supabase client — is real and load-bearing for frame rate, and utterly irrelevant to bandwidth once Unity enters the picture.
At a million room visits a day, the build alone projects to over 150 TB a month. That projection already assumes most requests are served warm from an edge or a browser cache; if nothing cached at all, a million cold fetches of a 28 MB payload is 28 TB a day, which is the arithmetic you should keep in your head as the worst case rather than the forecast. Either figure is the true ceiling on this site, and nothing in the web repository can fix it. There is no header, no rewrite, no bundler setting that makes thirty megabytes cost less than thirty megabytes on first contact.
The problem is not solved. It is characterised. Every mitigation below reduces how often the 28 MB is paid, never how much it is.
The Cloudflare trap
The obvious move is to put a CDN in front of the domain and let the edge absorb it. This does almost exactly nothing by default, and the reason is specific enough that it catches people who have configured CDNs before.
Cloudflare's default cache behaviour keys eligibility on file extension. There is a fixed list — .css, .js, .jpg, .woff2 and so on. .unityweb is not on it. .glb is not on it. The two largest files on the site are precisely the two the edge will not cache for you, so the marketing claim and the traffic graph diverge completely: your CSS is beautifully cached and every visitor still pulls thirty megabytes from origin.
The fix is an explicit Cache Rule scoped to the viewer path, marking it eligible and overriding edge TTL:
When incoming requests match:
(http.request.uri.path contains "/city/viewer/")
Then:
Cache eligibility ............. Eligible for cache
Edge TTL ..................... Override origin - 1 month
Browser TTL .................. Respect origin
Cache key -> Ignore query string .... No
Two settings on that path must stay off: CDN compression and minification. The files are already Brotli-compressed and the origin sets Content-Encoding: br from .htaccess. An edge that helpfully recompresses a Brotli stream either wastes CPU re-encoding bytes that will not shrink, or mangles the encoding header and hands the Unity loader something it cannot decode. Minification against a WASM payload is nonsense the moment it is attempted. The rule of thumb: the origin has already made every compression decision for this path, and the edge's job is transport, not opinion.
The header that is correct and insufficient
immutable is the right cache directive for a fingerprinted asset. It tells the browser not to revalidate at all — no conditional request, no 304 round trip, no wondering. It is also completely inert for the visitor who has never been here, which is the visitor generating all 150 TB. immutable optimises the second visit. The bandwidth bill is written by the first.
This matters because it is easy to ship a caching change, watch a synthetic test go from 30 MB to 0 bytes on reload, and believe the problem is addressed. It is not. Cache headers change the distribution of cold fetches over time; they do not change what a cold fetch costs.
The related policy in the web repository is about assets replaced in place — .glb files, thumb.*, room.json, rooms.config.js. These once carried no-cache, must-revalidate, which is correct for a browser and ruinous for a shared cache: it instructs every CDN edge to revalidate against origin on every single request. They now carry max-age=0, s-maxage=600, stale-while-revalidate=86400. Browser behaviour is unchanged; the trade is that replacing a file in place takes up to ten minutes to reach everyone. HTML makes the same bargain at s-maxage=120, so a deploy is globally visible within two minutes. The full reasoning lives in the .htaccess caching policy.
Why .glb URLs cannot be fingerprinted
Thumbnails carry a ?v=<hash-of-own-bytes> query fingerprint, so new pixels produce a new URL that no cache anywhere can answer from stale bytes. Geometry cannot do the same thing, for a reason that has nothing to do with caching theory.
Unity appends ".glb" to any path that does not already end in it. Hand the loader atrium2x5.glb?v=a3f91c and it sees a string that does not end in .glb, helpfully corrects you, and requests atrium2x5.glb?v=a3f91c.glb — a 404. So the geometry URL is stable by force, which is exactly why it needs the ten-minute s-maxage window rather than a fingerprint, and why replacing a room's model is an eventually-consistent operation rather than an instant one. Thumbnails get instant invalidation; geometry gets ten minutes. That asymmetry is a Unity string-handling quirk showing up as a caching policy, which is a good example of why the constraint list has to be read before the config is written.
The wins that actually matter are in Unity
Given the arithmetic above, effort spent on edge configuration has a hard ceiling and effort spent inside the Unity project does not. Three levers move the 28 MB in ways nothing on the web side can match:
- IL2CPP stripping level. The managed-code stripper removes unreferenced types from the generated C++ before it reaches WASM. Raising the stripping level is the largest single reduction available, at the cost of having to explicitly preserve anything reached by reflection.
- Texture compression. Uncompressed or naively compressed textures dominate the data file. Picking a GPU-appropriate format is the difference between shipping pixels and shipping compressed blocks.
- Dropping unused packages. A default project template pulls in modules that a room viewer never calls. Each one drags runtime code into the build regardless of whether a single line of it executes.
None of these are available from the web repository, because the build arrives as a finished artefact. That is a real organisational constraint and worth stating plainly rather than papering over: the person tuning cache rules and the person who can actually reduce the payload are working in different repositories, and only one of them can move the number that matters.
The harder question underneath all of this is whether a Unity player is the right renderer for a browser room at all. A hand-written WebGL scene loader has no runtime floor of that size, at the cost of building everything Unity gives you for free. That trade is a project-level decision rather than an optimisation, and it is worked through in choosing between Three.js and Unity.
What to take from this
Measure the payload before configuring anything around it. Confirm your CDN actually caches the extensions you care about rather than assuming the default list is complete. Keep edge compression away from paths the origin has already compressed. Treat immutable as a returning-visitor optimisation and never as a bandwidth answer. And when the dominant cost sits in an artefact you cannot rebuild, say so in the architecture notes instead of shipping a caching change that looks like a fix.
Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.
Arthis