Three.js or Unity WebGL: Choosing by What It Costs You
Both ship 3D to a browser; only one of them asks a first-time visitor for thirty megabytes before anything appears on screen.
I run two sites that both put 3D in a browser and answer this question in opposite directions. arthis.land shows rooms through a shared Unity WebGL build: 28–30 MB compressed, dropped in as an opaque artefact, serving every room on the site. arthis.space ships over a hundred single-file HTML games, canvas or WebGL written directly, between 23 kB and 150 kB each — a typical 3D one lands at 60–110 kB. Same browser, same GPU, same audience on the same phones. Two hundred times the difference in what a visitor downloads.
Neither choice was wrong. But the reasoning that makes each correct is specific, and most comparisons of these two get it backwards by starting with features. Start with bytes.
Download cost decides it more often than anything else
A Three.js scene starts at roughly 150 kB of runtime — the library, tree-shaken, with a loader and whatever controls you need — plus your assets. A Unity WebGL build starts at 28 MB before a single room model exists. That is the engine: the renderer, a physics module, a scene loader, the IL2CPP-generated runtime and its heap, all compiled into one WASM blob whose size is set by Unity's architecture rather than by your scene.
The part people expect to rescue them is a CDN, and it does not. A CDN changes the second visitor's experience, not the first one's. Edge caching moves bytes closer; it does not make them fewer. A first-time visitor on a cold cache downloads all 28 MB regardless of how many points of presence sit between them and your origin, and on arthis.land that is before any room geometry, which is a further 2.3–3.4 MB per room.
Worse, the CDN may not even help the second visitor unless you go and make it. Cloudflare's default cache list is keyed on file extension, and .unityweb and .glb are not on it. Putting the domain behind a CDN therefore changes precisely nothing for the two largest files on the site unless you add an explicit Cache Rule marking the viewer path eligible. That is the kind of detail that turns a confident architecture decision into a surprise bandwidth bill; I wrote up what the Unity build actually costs to serve separately, because the arithmetic deserves its own page. The short version: at a million room visits a day the Unity build alone projects to 150+ TB/month, and nothing in the web repository can move that number.
A 100 kB game has no such ceiling. It has no ceiling at all worth naming.
What Unity actually buys you
The honest case for Unity is that 28 MB is not waste — it is a large amount of software you did not write and would be foolish to rebuild. Specifically:
- An editor and a scene authoring workflow. Someone who is not a programmer can place, light and tune a room and see it as it will ship. This is the biggest one and the hardest to replicate.
- A physics engine with colliders, rigid bodies, raycasts and a solver that has had a decade of edge cases beaten out of it.
- Animation state machines — blend trees, transitions, layers, root motion. Writing the last forty per cent of this yourself is where projects die.
- Lighting and baked global illumination. Lightmapping, probes, reflection probes. A room that looks like a room rather than a diorama usually looks that way because of baked GI.
- An asset pipeline: import settings, texture compression per platform, addressables, streaming assets.
- A component model that gives a large team a shared vocabulary for composing behaviour.
- One project, many targets. The same scenes ship to iOS, Android, desktop and console. If a native app is anywhere in your plan, WebGL is one export rather than a second product.
Rebuilding even three of those in Three.js is a year of work, and the result will be worse. If your 3D is the product — a game with real physics, characters, a content team producing scenes weekly — that 28 MB is the cheapest thing you will buy all year.
What Three.js buys you
Three.js buys you ownership. The bundle is yours to cut: unused loaders, post-processing passes and controls never enter it, and when you need 40 kB back you can go and find 40 kB. There is no separate build tool, no editor licence, no export step between the code you write and the bytes you serve — it is a module in your app, built by the same Vite config as everything else.
It composes with the page. A Three.js canvas sits in your DOM, inherits your CSS custom properties, is positioned by your layout, and is torn down by your router like any other component. Debugging is JavaScript debugging: real stack traces with your function names, breakpoints in your source, the console you already know. A Unity WebGL error arrives as a stack trace through IL2CPP-generated C++ compiled to WASM, which is a genuinely different afternoon.
Most importantly, load time is under your control. You decide what is fetched, when, and in what order, and you can make first paint happen before any 3D loads at all. On the wall side of arthis.land that principle is absolute — the page paints from procedural data with zero network, and everything expensive arrives afterwards. You cannot apply that discipline to a 28 MB WASM module. It either loads or the viewer does not exist.
The integration question people underrate
A Unity WebGL build is an island. In practice it lives in an iframe, and the border between it and your page is postMessage — a string-shaped channel with no types, no shared memory and no synchronous calls. Every interaction between your UI and the scene becomes a protocol you design, version and debug from both sides.
It is also a security boundary, and the single most common way to get it wrong is to read event.data before checking who sent it. A listener without an origin check will happily accept commands from any frame that can get a handle on your window. This is not theoretical; it is how a dev-only panel on arthis.land got exposed.
// Host page <-> Unity iframe. Origin check first, always.
const VIEWER_ORIGIN = 'https://arthis.land';
const frame = document.getElementById('viewer');
window.addEventListener('message', (event) => {
// Without these two lines any frame on the page can drive the viewer.
if (event.origin !== VIEWER_ORIGIN) return;
if (event.source !== frame.contentWindow) return;
const msg = event.data;
if (!msg || typeof msg.type !== 'string') return;
if (msg.type === 'viewer:ready') {
frame.contentWindow.postMessage(
{ type: 'room:load', slug: 'glass-atrium' },
VIEWER_ORIGIN // never '*'
);
}
});
The Three.js equivalent of that entire block is calling a function. There is no border, so there is nothing to validate.
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // 3x on phones costs 2.25x the fill
document.getElementById('stage').appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.6, 4);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 1.2));
new GLTFLoader().load('/rooms/glass-atrium/atrium2x5.glb?v=8f2c1d', (gltf) => {
scene.add(gltf.scene);
renderer.setAnimationLoop(() => renderer.render(scene, camera));
});
Note the ?v= on that URL. Unity appends .glb to any path that does not already end in it, so a fingerprinted model URL becomes model.glb?v=8f2c1d.glb and 404s. Under Unity you cannot version model URLs at all; you replace bytes in place and live with cache windows. Under Three.js the fingerprint is free and an updated model is an unambiguously new URL that no cache can answer from. That is a small thing that costs a real operational policy.
Memory and mobile
A 28 MB WASM module on a mid-range Android is not the same proposition as a 200 kB scene. The download is the visible part; the invisible parts are the compile, the linear heap Unity reserves up front, and the fact that Safari on iOS will discard a backgrounded tab holding hundreds of megabytes and reload it from scratch. A Three.js scene's memory is your geometry and your textures, which means it is a number you can look up and reduce — and getting models under 3MB is the lever that actually moves it.
Text, accessibility and overlay
This one is not close. Three.js draws to a canvas and everything else stays DOM: real text nodes, real focus order, real screen-reader semantics, real Ctrl+F, labels that respect the user's font size, UI that reflows at 400px. A Unity build renders its own UI into the canvas, so every label inside it is pixels. Text drawn in a WebGL context is invisible to assistive technology and to selection. If your 3D needs captions, forms, links or any interface a keyboard user must reach, Three.js with HTML overlay wins outright and the argument is over.
Iteration speed and the team
Three.js iterates in the time it takes Vite to hot-reload — sub-second, state preserved. Unity iterates in the editor almost as fast, then takes minutes to produce a WebGL build, and WebGL-only bugs only appear after that build. The editor loop is excellent; the ship loop is not.
Team composition usually settles the question before the technology does. A studio with C# developers and 3D artists who live in the editor will be slower in Three.js, whatever the byte counts say. A web team with no Unity licences and no editor habits will ship faster in Three.js and will keep the whole stack debuggable. Choose the one your people already are.
The comparison
| Dimension | Three.js | Unity WebGL |
|---|---|---|
| Baseline download | ~150 kB runtime + assets | 28–30 MB before any content |
| Effect of a CDN | Meaningful — assets are small and cacheable | Nothing for first visit; nothing at all for .unityweb/.glb without an explicit cache rule |
| Asset versioning | ?v=hash on any URL | Blocked on models — Unity appends .glb to the path |
| Authoring | Code, plus Blender for models | Full editor; non-programmers can build scenes |
| Physics and animation | Bring your own library, or write it | Included, mature, integrated |
| Lighting / baked GI | Manual; bake elsewhere and import | First-class lightmapping and probes |
| DOM and CSS integration | Native — it is a module in your app | Iframe island with a postMessage border |
| Text and accessibility | Real DOM overlay, focusable, selectable | UI is pixels in a canvas |
| Debugging | Your JavaScript, your stack traces | IL2CPP → C++ → WASM |
| Mobile memory | Your geometry and textures | Large reserved heap; iOS tab discards hurt |
| Ship loop | Hot reload, sub-second | Editor fast, WebGL build slow |
| Other platforms | Web only | Same project to mobile, desktop, console |
The decision rule
Ship Unity WebGL when the 3D is the product and the editor is how your content gets made: real physics, animation state machines, baked lighting, non-programmers authoring scenes, or a native build in the plan. Accept in advance that the first visitor pays 28 MB, that your bandwidth ceiling is set by that file, that model URLs cannot be fingerprinted, and that the viewer will be an island behind a postMessage border you must validate on both ends.
Ship Three.js in every other case — and in particular whenever the 3D is one element of a page rather than the page, whenever text and accessibility matter, whenever mobile is the majority, and whenever load time is a number you are accountable for. If your whole experience fits in a few hundred kilobytes, go further and let it be one file; the single-file game approach is how a hundred 3D toys ship at 60–110 kB each with no build step at all.
The rule in one line: if you cannot name the specific Unity subsystem you would otherwise have to build, you do not need Unity, and 28 MB is the price of not having asked.
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