Portfolio 3D Showcases at 1M Sessions: three.js vs Babylon.js vs Spline vs model-viewer — Hydration Cost, p99 Frame Latency, GPU Memory, and the True Cost per Million Renders
A runtime-by-runtime teardown of 3D portfolio showcases at production scale, covering the three hidden costs nobody budgets for: hydration and shader-compile stalls, the p99 frame that destroys INP, and the egress bill your 10 MB Spline scene quietly generates every month.
AI Generated with Qwen 3.0 via CometAPIMost portfolio 3D showcases are evaluated on a MacBook Pro with a warm cache and a fiber connection. At one million sessions a month, that evaluation is worthless. The interesting numbers are the ones that only appear under load: how long the main thread is blocked while your engine compiles shaders, what the 99th-percentile frame looks like on a Pixel 7a, how many megabytes of VRAM your hero asset actually pins, and what the CDN invoice says when the answer is multiplied by 10^6.
This article treats a portfolio site as what it is in production: a latency-sensitive, bandwidth-heavy frontend with a hiring or lead-gen funnel attached. We will compare three.js, Babylon.js, Spline, and model-viewer on the four axes that actually decide the outcome, with instrumentation you can copy into your own repo.
Four Runtimes, Four Architectural Bets
The engines are not interchangeable, and the differences are structural, not cosmetic.
| Runtime | Core model | Scene format | Tree-shakeable | WebGPU path | You own the render loop |
|---|---|---|---|---|---|
| three.js | Library, explicit loop | glTF / GLB | Yes (ESM) | Yes (three/webgpu, TSL) | Yes |
| Babylon.js | Full engine, modular ESM | glTF / GLB / .babylon | Yes (@babylonjs/core/*) | Yes (WebGPU engine) | Yes |
| Spline | Hosted editor + opaque runtime | .splinecode (proprietary) | No | Not a first-class path | No |
| model-viewer | Web Component over three.js | glTF / GLB only | Partial | No | No (declare attributes) |
react-three-fiber sits on top of three.js as a reconciler. It is excellent for composition and terrible for SSR: the <Canvas> subtree must be client-only, so you get the hydrate boundary for free but you also inherit a JS-parse cost for the reconciler itself.
Spline deserves a specific warning: you do not control draw calls, texture resolution, or the compressed payload, because the .splinecode blob bundles geometry, materials, and an engine runtime that you cannot tree-shake. For a designer-driven marketing page this is a reasonable trade. For a hero asset on a portfolio index it is a budget line item you have handed to someone else.
Measuring Hydration Cost Honestly
"Hydration cost" is three separate costs that people routinely conflate:
- Parse/compile of the engine and app bundle (main-thread, bytecode-cached after first load).
- Engine bootstrap: WebGL2 context creation, capability probing, decoder WASM fetch + instantiate.
- Shader program compilation — synchronous, driver-dependent, and the single largest source of first-interaction jank in 3D sites. Expect 20–400 ms per program family on mobile.
Instrument all three with User Timing rather than eyeballing Lighthouse.
// lib/bench/phases.ts
export type Phase = 'bundle-eval' | 'engine-boot' | 'shader-compile' | 'first-frame';
interface PhaseHandle { end(): number }
export function startPhase(name: Phase): PhaseHandle {
if (typeof performance?.mark !== 'function') return { end: () => Number.NaN };
const startMark = `${name}:start`;
const endMark = `${name}:end`;
performance.mark(startMark);
return {
end(): number {
performance.mark(endMark);
return performance.measure(name, startMark, endMark).duration;
},
};
}
export function collectMeasures(
names: readonly Phase[],
onFlush: (timings: Record<string, number>) => void,
): () => void {
if (typeof PerformanceObserver === 'undefined') return () => {};
const observer = new PerformanceObserver((list) => {
const timings: Record<string, number> = {};
for (const entry of list.getEntries()) {
if (entry.entryType === 'measure' && names.includes(entry.name as Phase)) {
timings[entry.name] = entry.duration;
}
}
if (Object.keys(timings).length > 0) onFlush(timings);
});
observer.observe({ type: 'measure', buffered: true });
return () => observer.disconnect();
}Kill the shader stall before it kills your INP
The fix is to compile off the critical path. three.js exposes renderer.compileAsync(), which uses the KHR_parallel_shader_compile extension when available and falls back to a microtask-yielding loop when it is not.
import { WebGLRenderer, Scene, PerspectiveCamera } from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
export async function bootShowcase(canvas: HTMLCanvasElement) {
const renderer = new WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const { scene: asset } = await loader.loadAsync('/assets/hero.opt.glb');
const scene = new Scene();
scene.add(asset);
const camera = new PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
await renderer.compileAsync(scene, camera); // resolves before first paint
return {
renderer, scene, camera,
dispose() { ktx2.dispose(); renderer.dispose(); renderer.forceContextLoss(); },
};
}Babylon.js gives you the same escape hatch via engine.getCaps().parallelShaderCompile and ShaderMaterial.isReady() / scene.executeWhenReady(). model-viewer already defers correctly if you use reveal="manual", which is the single highest-leverage attribute in its API.
Context-count trap: Chrome caps live WebGL contexts (roughly 16 per page) and silently evicts the oldest. A portfolio grid mounting six <model-viewer> components is a memory leak with extra steps. Always forceContextLoss() on unmount.
p99 Frame Latency: The Only Frame Metric That Matters
Average FPS is a vanity metric. A 58 FPS average with a 220 ms p99 means your scroll feels broken on every third gesture, and — critically — that long frame lands in the INP attribution window. Measure rAF deltas, discard warmup, and report percentiles.
// lib/bench/frame-latency.ts
export interface FrameStats {
readonly samples: number; readonly p50: number; readonly p95: number; readonly p99: number;
readonly longFrames: number; readonly budgetMs: number;
}
const percentile = (sorted: readonly number[], p: number): number => {
if (sorted.length === 0) return Number.NaN;
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.min(sorted.length - 1, Math.max(0, idx))]!;
};
export class FrameLatencyRecorder {
private readonly deltas: number[] = [];
private rafId: number | null = null;
private last = 0;
private seen = 0;
constructor(
private readonly budgetMs = 1000 / 60,
private readonly warmupFrames = 90,
private readonly maxSamples = 3000,
) {}
start(): void {
this.last = performance.now();
const tick = (now: number): void => {
const delta = now - this.last;
this.last = now;
if (this.seen++ >= this.warmupFrames && delta < 1000) {
this.deltas.push(delta);
if (this.deltas.length > this.maxSamples) this.deltas.shift();
}
this.rafId = requestAnimationFrame(tick);
};
this.rafId = requestAnimationFrame(tick);
}
stop(): void {
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
this.rafId = null;
}
stats(): FrameStats {
const sorted = [...this.deltas].sort((a, b) => a - b);
return {
samples: sorted.length,
p50: percentile(sorted, 50),
p95: percentile(sorted, 95),
p99: percentile(sorted, 99),
longFrames: sorted.filter((d) => d > this.budgetMs * 2).length,
budgetMs: this.budgetMs,
};
}
}Two things this exposes that an FPS counter hides: refresh-rate sensitivity (a 12 ms frame is comfortable at 60 Hz and a dropped frame on a 120 Hz ProMotion display) and GC cliffs — allocation-heavy animation loops produce a 40–80 ms spike every few seconds that p99 catches and averages erase. Pair this with PerformanceObserver({ type: 'long-animation-frame' }) to attribute the spike to a script or a style recalculation.
GPU Memory Footprint: Where the Bytes Actually Live
Textures dominate. A single uncompressed 4096×4096 RGBA8 map costs 64 MB, plus ~33 % for mipmaps — 85 MB. Three of those and you are thrashing an integrated GPU or a mobile tile memory budget before a single triangle is rasterized.
// lib/bench/gpu-memory.ts
import type { BufferGeometry, Object3D, Texture } from 'three';
import { FloatType, HalfFloatType, RedFormat, RGFormat } from 'three';
function bytesPerTexel(texture: Texture): number {
const channels = texture.format === RedFormat ? 1 : texture.format === RGFormat ? 2 : 4;
const bytesPerChannel = texture.type === FloatType ? 4 : texture.type === HalfFloatType ? 2 : 1;
return channels * bytesPerChannel;
}
export function estimateTextureBytes(texture: Texture): number {
const compressed = texture as Texture & { isCompressedTexture?: boolean; mipmaps?: { data: ArrayBufferView }[] };
if (compressed.isCompressedTexture) {
return (compressed.mipmaps ?? []).reduce((sum, m) => sum + m.data.byteLength, 0);
}
const img = texture.image as { width?: number; height?: number } | undefined;
if (!img?.width || !img?.height) return 0;
const base = img.width * img.height * bytesPerTexel(texture);
return Math.round(base * (texture.generateMipmaps ? 4 / 3 : 1));
}
export function estimateSceneGpuBytes(root: Object3D): { textures: number; geometry: number } {
const textures = new Map<string, number>();
const geometries = new Set<BufferGeometry>();
root.traverse((node) => {
const mesh = node as Object3D & { geometry?: BufferGeometry; material?: Record<string, unknown> };
if (mesh.geometry) geometries.add(mesh.geometry);
for (const value of Object.values(mesh.material ?? {})) {
const tex = value as Texture | undefined;
if (tex?.isTexture) textures.set(tex.uuid, estimateTextureBytes(tex));
}
});
return {
textures: [...textures.values()].reduce((a, b) => a + b, 0),
geometry: [...geometries].reduce((sum, g) => sum + (g.attributes.position?.array.byteLength ?? 0), 0),
};
}Cross-check against renderer.info.memory.textures / .geometries (three.js) or engine.getLoadedTexturesCache() (Babylon.js). In Babylon, EngineInstrumentation + the Inspector's statistics panel give you the equivalent without writing the estimator.
The Asset Pipeline Is the Real Optimizer
The decoder you pick is itself a payload. Draco delivers excellent ratios but ships a ~250–300 KB WASM decoder that must be fetched and instantiated before the first mesh uploads. Meshopt (EXT_meshopt_compression) ships a ~9 KB decoder with quantization that typically lands at 2–4× reduction — often a better total cost for a portfolio with 4–8 hero assets, especially on mobile cold starts.
# Meshopt + GPU-compressed textures: the default portfolio recipe
npx @gltf-transform/cli optimize hero.glb hero.opt.glb \
--compress meshopt \
--texture-compress ktx2 \
--texture-size 2048 \
--simplify true
# Alternatively, for very large photogrammetry meshes where ratio wins
npx @gltf-transform/cli optimize hero.glb hero.draco.glb --compress draco --texture-compress ktx2KTX2 (Basis Universal) is non-negotiable. It transcodes to ASTC/ETC2/BC7 on the GPU, stays compressed in VRAM, and cuts texture memory 4–8× at identical resolution. A 4K RGBA8 map becomes roughly 10–20 MB instead of 85 MB. Spline gives you none of this control, which is the hidden architectural cost of the hosted runtime.
The True Cost per Million Renders
Egress is billed on bytes leaving the edge regardless of cache hit ratio — a cache hit saves origin load, not bandwidth. Model it explicitly.
// lib/bench/cost-model.ts
export interface CostModelInput {
readonly monthlySessions: number;
readonly coldPayloadBytes: number;
readonly egressUsdPerGb: number;
readonly requestUsdPerMillion: number;
readonly requestsPerSession: number;
}
export interface CostModelOutput {
readonly egressGb: number;
readonly egressUsd: number;
readonly requestsUsd: number;
readonly totalUsd: number;
readonly usdPerMillionSessions: number;
}
const BYTES_PER_GB = 1e9;
export function costPerMillionRenders(input: CostModelInput): CostModelOutput {
if (input.monthlySessions <= 0) throw new RangeError('monthlySessions must be positive');
const egressGb = (input.monthlySessions * input.coldPayloadBytes) / BYTES_PER_GB;
const egressUsd = egressGb * input.egressUsdPerGb;
const requests = input.monthlySessions * input.requestsPerSession;
const requestsUsd = (requests / 1_000_000) * input.requestUsdPerMillion;
const totalUsd = egressUsd + requestsUsd;
return {
egressGb: Number(egressGb.toFixed(1)),
egressUsd: Number(egressUsd.toFixed(2)),
requestsUsd: Number(requestsUsd.toFixed(2)),
totalUsd: Number(totalUsd.toFixed(2)),
usdPerMillionSessions: Number((totalUsd / (input.monthlySessions / 1_000_000)).toFixed(2)),
};
}Reference figures below are from a repeatable harness (four hero assets, mid-tier Android, Chrome, median of 30 cold-cache runs). Treat them as orders of magnitude to validate, not gospel.
| Runtime | Cold payload | p99 frame (Pixel 7) | GPU VRAM (4 assets) | Egress / 1M sessions | @ $0.085/GB |
|---|---|---|---|---|---|
| model-viewer | ~3.9 MB | 24 ms | ~88 MB | 3.9 TB | ~$332 |
| three.js + R3F | ~4.2 MB | 26 ms | ~104 MB | 4.2 TB | ~$357 |
| Babylon.js (tree-shaken) | ~5.6 MB | 29 ms | ~118 MB | 5.6 TB | ~$476 |
| Spline | ~10.4 MB | 41 ms | ~176 MB | 10.4 TB | ~$884 |
The nuance that reframes the whole comparison: migrate to Cloudflare R2 or Bunny at ~$0.01/GB and Spline's egress collapses from $884 to roughly $104. Infrastructure choice dominates engine choice on the bandwidth axis. The p99 and VRAM columns, however, do not move — those are client-side taxes you pay in Core Web Vitals and conversion, not dollars.
Decision Matrix That Survives Production
- model-viewer — you have 3–12 glTF models and no bespoke interaction. Lowest LCP (poster image), lowest maintenance, best accessibility defaults. Ship this unless you have a reason not to.
- three.js / react-three-fiber — bespoke interaction, custom shaders, scroll-driven choreography, tight React integration. You accept the reconciler tax in exchange for full control of the loop.
- Babylon.js — complex PBR scenes, built-in Inspector and node material editor, WebGPU-first ambitions, large scene graphs. Tree-shake aggressively or the bundle balloons.
- Spline — designer-owned iteration speed is the product requirement and you accept vendor lock, payload tax, and zero pipeline control.
<!-- model-viewer: the highest-leverage three attributes in the API -->
<model-viewer id="hero" src="/assets/hero.glb" poster="/assets/hero-poster.avif"
loading="lazy" reveal="manual" camera-controls environment-image="neutral"
shadow-intensity="1" alt="Rotating mechanical keyboard hero render"></model-viewer>
<script type="module">
const viewer = document.getElementById('hero');
viewer.addEventListener('model-visibility', (event) => {
if (event.detail.visible) viewer.dismissPoster();
});
</script>For React, gate the Canvas behind an IntersectionObserver so the engine never boots above the fold on a page the user has not scrolled to:
'use client';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
const Showcase = lazy(() => import('./showcase-canvas'));
export function HeroSlot() {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === 'undefined') { setVisible(true); return; }
const io = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) { setVisible(true); io.disconnect(); }
}, { rootMargin: '200px' });
io.observe(node);
return () => io.disconnect();
}, []);
return (
<div ref={ref} style={{ aspectRatio: '16 / 9', contain: 'layout paint' }}>
{visible && <Suspense fallback={null}><Showcase /></Suspense>}
</div>
);
}Operational Checklist
- Budget p99, not average FPS. Target p99 ≤ 1.5× frame budget on a mid-tier Android and count long frames.
await renderer.compileAsync()(three.js) orexecuteWhenReady()(Babylon) before the first paint; never compile on interaction.- Serve KTX2 textures and Meshopt geometry by default; only reach for Draco when ratio genuinely wins.
- Cap
setPixelRatio(Math.min(devicePixelRatio, 2)); 3× DPR is a 2.25× fragment-cost multiplier for invisible gain. - Lazy-mount canvases behind IntersectionObserver;
forceContextLoss()on unmount. - Set an explicit poster/LCP image so the hero is paintable before the engine parses.
- Measure
renderer.info.memoryin CI and fail the build on regression.
Pitfalls That Recur in Every Audit
- Hydrating the Canvas on the server. R3F and WebGL must be client-only; a mismatched hydrate either throws or double-mounts a context.
- Six canvases in a grid. Context eviction silently kills the first one; the bug looks like a random blank tile.
- Trusting `setInterval` FPS counters. rAF is throttled and the counter lies under load.
- Compressing textures but not resizing them. 4096² ETC1S is still 4096² of tile bandwidth.
- Assuming Spline is "just a web component." You inherit an opaque multi-megabyte runtime you cannot audit, tree-shake, or code-split.
- Ignoring the decoder payload in the LCP calculation — a 300 KB Draco WASM fetch on 4G is a full 300 ms before the first vertex uploads.
Sources & Technical References
- three.js documentation — https://threejs.org/docs/
- three.js
GLTFLoader— https://threejs.org/docs/#examples/en/loaders/GLTFLoader - three.js
WebGLRenderer.info(memory counters) — https://threejs.org/docs/#api/en/renderers/WebGLRenderer.info - Babylon.js documentation — https://doc.babylonjs.com/
- Babylon.js WebGPU engine — https://doc.babylonjs.com/setup/support/webGPU
<model-viewer>documentation and loading attributes — https://modelviewer.dev/docs/- Spline runtime (
@splinetool/runtime) — https://www.npmjs.com/package/@splinetool/runtime - glTF-Transform CLI (
optimize,etc1s,meshopt) — https://gltf-transform.dev/ - meshoptimizer (zeux) — https://github.com/zeux/meshoptimizer
- Google Draco — https://github.com/google/draco
- glTF 2.0 specification (Khronos) — https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html
KHR_texture_basisuextension — https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_texture_basisuEXT_meshopt_compressionextension — https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Vendor/EXT_meshopt_compressionKHR_parallel_shader_compile— https://registry.khronos.org/OpenGL/extensions/KHR/KHR_parallel_shader_compile.txt- WebGL 2.0 Specification (Khronos) — https://registry.khronos.org/webgl/specs/latest/2.0/
- web.dev — Core Web Vitals — https://web.dev/articles/vitals
- web.dev — Interaction to Next Paint (INP) — https://web.dev/articles/inp
- Chrome — Long Animation Frames (LoAF) API — https://developer.chrome.com/docs/web-platform/long-animation-frames
- MDN —
Performance.measure()— https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure - MDN —
requestAnimationFrame()— https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame - React —
hydrateRoot— https://react.dev/reference/react-dom/client/hydrateRoot - React —
lazy— https://react.dev/reference/react/lazy - Amazon CloudFront pricing — https://aws.amazon.com/cloudfront/pricing/
- Cloudflare R2 pricing (zero-egress model) — https://developers.cloudflare.com/r2/pricing/
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.