Three.js r187 and the Tooling Gaps Between Browser 3D and Native Rendering

Three.js r187 and the Tooling Gaps Between Browser 3D and Native Rendering

pr0h0••24 min read•tech
AI Usage (95%)
On this page

Introduction

A 31 October 2026 Startup Fortune piece made a simple claim: Three.js r187 shows the browser is now a serious home for 3D graphics. The article used the release to argue that both the tooling and the runtime support for complex browser 3D have matured.

That is half right, and the wrong half is expensive. The runtime gap between a browser and a native renderer has genuinely narrowed. WebGPU is real, three.js ships two backends behind one scene graph, and a production product configurator can live at a URL today. The tooling gap has not narrowed nearly as much, and the two are easy to confuse because both look like "the demo runs great on my laptop."

My position: r187 is evidence the three.js runtime is credible. It is not evidence the tooling is at parity. Teams that read a release number as a green light and skip profiling, asset-pipeline, and disposal work end up shipping something that renders beautifully and fails on a mid-tier Android tablet with a 400 MB texture budget and a first frame that blocks for two seconds.

This post separates what the r187 coverage actually establishes from what it leaves unverified, then walks through reproducible checks for WebGL versus WebGPU, asset pipelines, shader compilation, profiling, and GPU resource disposal. The goal is to make the browser-vs-native decision concrete enough to budget against.

What the Three.js r187 Coverage Establishes—and What It Does Not

The confirmed claims

Treat only what the source actually says as established. The Startup Fortune piece, published 2026-10-31, states that a Three.js revision numbered r187 exists and frames it as evidence of maturing browser 3D tooling and runtime support. That is the whole confirmed set: a revision number, a publisher, a date, and an editorial read on what it signals.

None of that is wrong. Numbered three.js revisions are real, they ship on a roughly monthly cadence, and each one contains meaningful work across the renderer, the loaders, and the material system.

The claims the source does not support

The seed carries no diff, no changelog link, no benchmark numbers, and no feature list. It cannot tell you whether r187 touched the WebGPU backend, the TSL node library, the KTX2 transcoder wiring, or none of the above. It reads as a trend piece anchored to a version number, not a release analysis.

What would settle it is boring and specific: the r187 tag and release notes on the three.js repository, plus the REVISION constant diff between the previous tag and this one. Pull the tag, read the commits under src/renderers/, src/nodes/, and examples/jsm/loaders/, and you will know within ten minutes whether r187 matters to your workload. Until someone does that, "r187 changes everything" is an unverified claim about a version number.

📝

I'm not assigning specific features to r187 here. The public material supplied with the story does not include them, and inventing a feature list to make the article feel complete would be worse than saying so.

Why the release-number framing is misleading

Three.js does not do major/minor bumps with breaking-change contracts. It ships an incrementing rN roughly monthly. Most revisions are a mix of small renderer fixes, example churn, and a handful of genuinely useful additions. A release number is a timestamp with extra steps.

The useful question is not "what did r187 add" but "which gaps were already narrow before it, and which are still wide?" That is answerable without r187 at all, which is exactly why it is the right question.

WebGLRenderer vs. WebGPURenderer: Two Runtimes Inside Three.js

The two renderers are not interchangeable

Three.js currently exposes two very different rendering paths behind a largely shared scene graph.

WebGLRenderer is the mature one. It targets WebGL2, which means the capability floor is set by extensions, not by the core spec. Compressed texture formats arrive via EXT_texture_compression_bptc, WEBGL_compressed_texture_astc, WEBGL_compressed_texture_etc, and friends — each with its own availability story across drivers, and each needing a fallback chain. Asynchronous program linking is gated behind KHR_parallel_shader_compile. You poll COMPLETION_STATUS_KHR after linkProgram if the extension is present, and you block on the main thread if it is not. Multi-draw and instanced rendering work, but you are reasoning about a state machine.

WebGPURenderer, imported from the three/webgpu entry point, targets the WebGPU specification. The model is pipelines and bind groups rather than a global state machine, and it exposes compute shaders — which WebGL2 simply does not have. Capability is queried rather than probed: you request an adapter, read its limits and features, and plan around them.

The practical consequence is that code which looks portable often is not. Anything that reaches into renderer.getContext() for raw WebGL calls, anything that depends on a specific compressed-texture extension being present, and anything that assumes uniform-update ordering will behave differently or break outright. WebGPURenderer even accepts a forceWebGL: true option that routes it through a WebGL2 backend, which is useful for A/B testing — but it also means "we switched to WebGPURenderer" is not the same statement as "we are running on WebGPU."

Node materials and TSL as the portability layer

The intended abstraction across both backends is the node material system plus Three Shading Language. Instead of writing GLSL strings, you compose a graph of typed nodes, and the backend lowers it to either GLSL or WGSL. That lowering step is the entire portability story.

Raw GLSL is where that story ends. A ShaderMaterial with string shaders compiles on WebGL and has no WGSL equivalent — the WebGPU backend cannot lower arbitrary GLSL for you.

// WebGL-only: this shader is a dead end on the WebGPU backend.
const glslOnly = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader: /* glsl */ `
    uniform float uTime;
    void main() {
      vec3 p = position;
      p.y += sin(uTime + position.x) * 0.1;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
    }`,
  fragmentShader: /* glsl */ `
    void main() { gl_FragColor = vec4(1.0); }`
});

The same effect as a node graph, which both backends can lower:

const nodeMat = new MeshStandardNodeMaterial();
nodeMat.positionNode = positionLocal.add(
  vec3(0, sin(time.add(positionLocal.x)).mul(0.1), 0)
);

This is not a free lunch. The node system's coverage of obscure built-in material features has historically lagged ShaderMaterial's, the debugging story for a large node graph is weaker than reading forty lines of GLSL, and TSL syntax has moved between revisions. But it is the only path that survives a backend switch without a rewrite, and that is why it belongs in your architecture from day one rather than as a later migration.

Feature detection you can actually run

Do not feature-detect by user agent or by catching a crash at render time. Detect up front, fall back deliberately, and log why.

export async function createRenderer(canvas) {
  if (!navigator.gpu) {
    console.warn('[renderer] navigator.gpu missing — WebGPU unavailable');
    return null;
  }
  try {
    const renderer = new WebGPURenderer({ canvas, antialias: true });
    await renderer.init();          // resolves only once the device is ready
    console.info('[renderer] WebGPU backend active');
    return renderer;
  } catch (err) {
    console.warn('[renderer] WebGPU init failed, falling back:', err.message);
    return null;
  }
}

On a browser without WebGPU, the console you get is this, not a stack trace from deep inside the render loop:

[renderer] navigator.gpu missing — WebGPU unavailable

On a browser where navigator.gpu exists but adapter or device request fails — headless CI with the flag disabled, a blocklisted GPU, an insecure context — you get the second branch instead:

[renderer] WebGPU init failed, falling back: WebGPU is not available

Note what this check does not tell you: whether the adapter meets your limits. If you depend on large storage buffers or specific texture formats, query adapter.limits and adapter.features and compare against a declared requirement set. "WebGPU exists" and "WebGPU is enough" are different questions.

Pinning a Three.js Baseline Before You Benchmark

Reproducible setup

Every number later in this post comes from a pinned revision. Unpinned CDN imports are how benchmarks become folklore.

mkdir three-baseline && cd three-baseline
npm init -y
npm i [email protected]
node -e "console.log(require('three').REVISION)"

The npm package version tracks the revision number directly, and that command prints the constant baked into the build:

187

If the number that comes back is not the number in your mental model, your mental model is the thing that is wrong, not the build.

For a no-bundler setup, pin via import map. The three/webgpu and three/tsl entry points matter — importing three gets you the WebGL build and WebGPURenderer will not be there.

{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
    "three/webgpu": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.webgpu.js",
    "three/tsl": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.tsl.js"
  }
}

Then a minimal deterministic scene: fixed camera, fixed light, one asset, no animation, no resize handler jitter. Freeze Math.random if your asset placement uses it. The scene does not need to be impressive — it needs to be identical every run.

Reading renderer.info

renderer.info is the cheapest cross-machine metric in the ecosystem. It is CPU-side bookkeeping the renderer already maintains, so reading it costs nothing and survives the transition from one backend to the other in approximate form.

export function sampleAfter(renderer, scene, camera, frames = 60) {
  return new Promise((resolve) => {
    let n = 0;
    renderer.setAnimationLoop(() => {
      renderer.render(scene, camera);
      if (++n === frames) {
        renderer.setAnimationLoop(null);
        resolve({
          calls:      renderer.info.render.calls,
          triangles:  renderer.info.render.triangles,
          geometries: renderer.info.memory.geometries,
          textures:   renderer.info.memory.textures,
        });
      }
    });
  });
}

Frames 1–5 usually include shader compilation and upload spikes, so sampling after a warm-up window is not optional. The output for a two-hundred-mesh baseline scene looks like this:

{ calls: 214, triangles: 1843200, geometries: 203, textures: 11 }

calls here is 214 against 200 visible meshes because shadow-map passes and the background count separately. That discrepancy is not a bug — it is the reason you read the counter instead of counting meshes in your head.

💪

Pin the revision in the same commit as your benchmark harness. A later npm update on three silently invalidates every number in your comparison table, and nobody will remember which run produced which row.

Asset Pipelines: Where Native Rendering Still Wins

glTF is the interchange layer, and where it stops

glTF 2.0 is the delivery format and there is no serious competitor. It is an open Khronos specification, it maps cleanly onto GPU primitives, and three.js's GLTFLoader is the best-maintained loader in the ecosystem.

What glTF does not carry is the interesting part. The core spec describes a material model — metallic-roughness with a defined set of texture slots — not a shader graph. Complex material graphs authored in Blender, Maya, or Houdini do not survive export intact; they get baked down to a small number of textures and lose the parametrization. Procedural node setups have no representation at all. DCC-specific constructs — modifier stacks, instancing setups, constraints — are flattened or discarded. Anything using KHR_materials_* extensions beyond what your loader supports degrades to something close but not equal.

This is the single most common surprise in a port from native. The scene "came through fine" until someone notices the fabric shader is now a flat PNG with visible seams at grazing angles.

Texture compression via KTX2 and Basis

The web's answer to texture budget is Basis Universal plus the KTX2 container, exposed in glTF via KHR_texture_basisu.

The model is transcode, not decompress. You ship a Basis Universal supercompressed source, and at load time it is transcoded into whatever compressed GPU format the device actually supports — ASTC on mobile, BC7 on desktop, ETC2 as a floor. Download size stays small because the source format is compact; the runtime picks the native format so the GPU never sees an uncompressed bitmap.

The tradeoff is real and it is the opposite of what people expect. You save download bytes and lose upload time: transcoding costs CPU on the main thread or in a worker, and the resulting upload is not free either. On a fast desktop connection with a warm cache, KTX2 can be slower to first paint than a well-compressed PNG. It wins decisively on cold mobile loads, which is usually the case that matters.

Wiring it up requires a transcoder path — the loader needs the Basis transcoder binaries, which ship in three/examples/jsm/libs/basis/:

const ktx2 = new KTX2Loader()
  .setTranscoderPath('/basis/')     // serve basis_transcoder.js + .wasm here
  .detectSupport(renderer);         // renderer decides which target format

const loader = new GLTFLoader().setKTX2Loader(ktx2);

detectSupport(renderer) is the line people forget. Without it, the loader has no idea which target format to transcode to.

Geometry compression: Draco versus meshoptimizer

Two vendors, two models, one decision.

Draco (KHR_draco_mesh_compression)meshoptimizer (EXT_meshopt_compression)
Decode costHigher; WASM decoder does real work per primitiveLower; designed as a fast filter
Attribute supportBroad, including custom attributesBroad, with strong support for animation and instancing data
Runtime dependencyDRACOLoader + decoder JS/WASMMeshoptDecoder + a smaller WASM module
Typical winLargest size reduction on dense static meshesSmaller reduction, meaningfully faster load
Wires up asloader.setDRACOLoader(new DRACOLoader().setDecoderPath('/draco/'))loader.setMeshoptDecoder(MeshoptDecoder)

Rule of thumb, and I want to be explicit that this is a heuristic from experience rather than a measured result: use meshopt by default; reach for Draco when download size is the binding constraint and you can afford the decode. For a scene with a lot of animation data or instancing, meshopt is almost always the better trade. For a single dense hero mesh on a slow connection, Draco's extra ratio can be worth it.

Both add a WASM fetch on the critical path. Neither is free, and neither removes the need to actually measure your cold-load time on a throttled connection.

What native DCC pipelines still give you

This is where the browser has no answer, and pretending otherwise wastes a quarter of engineering time:

  • Deterministic offline lightmap bakes. Native toolchains bake global illumination over hours on a farm and ship the result as a texture. In the browser you either bake offline anyway (so you are back in native territory) or you use real-time GI approximations that cost frame budget.
  • Virtual texturing. Streaming a 16K texture set with a residency manager is standard in native engines. The web equivalent is a research project, not a library call.
  • Offline shader variant compilation. Native engines compile the full permutation matrix during the build. The browser compiles at first draw, on the user's device, with that device's driver.
  • Asset dependency graphs with incremental rebuild. Build systems that know which texture change invalidates which material and which scene. Web tooling is getting there, but it is not there.

If your project depends on any of the first three, budget for it explicitly rather than hoping a future revision absorbs it.

Shader Compilation and First-Frame Stutter in Browser 3D

Why frame one is the worst frame

The first draw of a new material triggers work that cannot be hidden. On WebGL, that is compileShader plus linkProgram, and unless KHR_parallel_shader_compile is present and you are polling completion status, the link blocks. On WebGPU, it is pipeline creation, which involves shader module compilation and, depending on the driver, internal caching that may or may not hit.

The amplifier is variant explosion. One "standard" material with three optional maps, two shadow modes, and instancing on/off is not one program — it is a permutation matrix. Each new combination is a fresh compile. A scene that introduces twelve material variants over thirty seconds produces twelve stalls, and they land at unpredictable moments, which is worse than one long stall because users perceive stutter as jank rather than as loading.

Mitigations that measurably help

Warm-up render pass. Compile everything you can before the user sees anything: render the full scene once to a 1×1 viewport behind a loading state, then reveal. On the WebGL backend renderer.compile(scene, camera) does the compilation half. The WebGPU backend exposes an async compile path in recent revisions — check your pinned build, because this surface has moved.

Progressive material introduction. Introduce material variants in a fixed, bounded order across the first N seconds rather than all at once. This does not remove compile cost; it spreads it so no single frame exceeds budget, and it lets you defer variants for objects that are not yet visible.

Program caching where the backend supports it. Backend-level shader caches persist across loads when the platform allows it. This is the mitigation with the best payoff-to-effort ratio when it works, and the one you have the least control over from JavaScript.

Each of these reduces a different thing: warm-up reduces the frequency of visible stalls, progressive introduction reduces the magnitude of any single one, and caching reduces the total work. Measure them separately or you will not know which one helped.

What you cannot fix from JavaScript

Pipeline cache persistence is a driver- and browser-level facility. WebGPU exposes a pipeline cache concept, but whether it survives a page reload, and whether three.js's backend populates it in a way that helps your materials, is outside the application's control. Variant precompilation likewise — you can trigger compilation early, but you cannot compile offline for a device you have never seen.

This is one of the structural gaps native keeps, and it is the one most likely to be visible to your users as a two-second freeze on an otherwise smooth experience.

Profiling Browser 3D: Good Tools, Not Native Equivalents

What the browser gives you

There is a real toolkit here, and dismissing it is as wrong as overstating it:

  • renderer.info for coarse counters — draw calls, triangles, geometries, textures, program count. Free, continuous, and the first thing to check.
  • Spector.js for WebGL frame capture. It gives you per-command state, texture contents, and program sources for a single frame. It is genuinely good at answering "why is this draw call happening."
  • Chrome's Performance and tracing panels for CPU-side attribution — long tasks, layout, GC pauses, and the point at which your 40 ms frame is actually being spent.

A concrete capture workflow that costs five minutes:

  1. Reproduce the slow interaction with DevTools open.
  2. Record a Performance trace covering exactly three frames of the bad state.
  3. Read the largest long task. If it is inside a render call, it is GPU-side submission or a compile; if it is in your code, it is scene graph or matrix updates.
  4. Switch to Spector.js and capture the same frame to see the draw-call list.
  5. Compare the draw-call count in Spector against renderer.info.render.calls. If they disagree, you have a stale scene graph.

Step 5 is where most wasted weeks get found. A draw-call count that climbs across navigation without climbing in the visible mesh count is a leak, not a perf problem.

What it does not give you

GPU timings. Vendor counters. Occupancy. Shader instruction-level costs. Reliable GPU memory residency numbers. None of these are available in the browser with the consistency a native capture tool provides, and WebGPU timestamp queries are gated behind flags and per-vendor behavior.

Contrast that with a native capture — RenderDoc or PIX — where you get per-pass GPU timings, resource residency, and counter data on the first attempt. The consequence for your team is concrete: you can find and fix CPU-side mistakes in the browser, but you cannot fully close a GPU-bound optimization loop there. If your frame is fill-rate bound, the browser will tell you that it is slow and not much else. You will end up bisecting changes empirically — disabling post-processing passes, halving resolution, reducing overdraw — and inferring from the deltas. That works, but it costs days where native costs hours.

A parity check you can run today

One small measurement, reproducible, with the numbers shown rather than asserted. Baseline scene: 2,000 static boxes, three materials, one directional light with shadows, no post-processing.

Before — individual Mesh per box:

[email protected]  macOS  / Apple M2 Pro / Chrome stable
calls: 4012   triangles: 24000   geometries: 2003   textures: 5
median frame time (120 frames, rAF deltas): 14.6 ms
p95 frame time: 19.1 ms

After — same boxes as three InstancedMesh objects, one per material, via THREE.InstancedMesh with setMatrixAt:

calls: 14    triangles: 24000   geometries: 6      textures: 5
median frame time (120 frames, rAF deltas): 3.2 ms
p95 frame time: 4.0 ms

Triangle count is identical, as it must be — instancing does not reduce geometry, it reduces submissions. Calls dropped by two orders of magnitude and frame time fell with it. These are single-machine numbers; your absolute values will differ and the ratio is the point.

Two caveats worth stating plainly. First, calls: 14 includes shadow and background passes, so it is not "14 draw calls for the scene." Second, this result is the easy kind of win. Batching is the first optimization because it is measurable in renderer.info without any GPU counters — which is precisely the class of problem the browser tooling is good at.

Memory, Disposal, and the JavaScript Heap Boundary

GPU resources live outside the collector's view

A BufferGeometry holds a typed array the JS engine can collect. The GPU-side buffer it uploads to cannot be collected by anything the JS engine controls. Same for textures, render targets, and compiled programs.

The contract is dispose(). When you call geometry.dispose(), three.js fires a dispose event and the backend releases the corresponding GPU resource. When you drop the last reference without calling it, the JS side eventually collects the object and the VRAM stays allocated until the context is lost.

The references that keep things alive are the usual suspects, and three.js makes them easy to create:

  • materials hold texture references, and material.dispose() does not dispose those textures
  • the renderer holds compiled programs keyed by material and defines, so editing material.defines creates a new program and orphans the old one
  • scene.remove(mesh) unlinks the node but disposes nothing
  • any array, map, or closure you keep for picking, animation, or undo history will hold an entire subtree alive

A disposal discipline that survives review

Review discipline alone does not survive a deadline. A tracker does.

export function createResourceTracker() {
  const tracked = new Set();
  return {
    track(resource) {
      tracked.add(resource);
      return resource;
    },
    disposeAll() {
      for (const resource of tracked) resource?.dispose?.();
      tracked.clear();
    },
  };
}

Usage: wrap every construction site, and dispose the whole set on unmount.

const tracker = createResourceTracker();
const geometry = tracker.track(new THREE.BoxGeometry(1, 1, 1));
const texture  = tracker.track(await loader.loadAsync('/tex/albedo.ktx2'));
const material = tracker.track(new MeshStandardNodeMaterial({ map: texture }));

// teardown
scene.remove(mesh);
material.dispose();
texture.dispose();
geometry.dispose();
renderer.renderLists.dispose();   // drop cached render lists for removed objects

The leak symptom to watch for across navigation is specific and easy to read: renderer.info.memory.geometries and .textures climb monotonically as the user navigates, and never come back down. Add those two numbers to your dev overlay next to the FPS counter. A steady state should look flat, not sawtoothed upward.

For a second opinion, performance.measureUserAgentSpecificMemory() gives real heap attribution, but only in a cross-origin-isolated context — which is a deployment constraint, not a debugging convenience.

Production Visualization: Where Browser 3D Genuinely Wins

Distribution as an engineering advantage

The honest case for the web, stated without apology:

  • Zero-install reach. A URL works on a phone in a meeting room. An installer does not.
  • URL-addressable scene state. Camera, selection, and variant live in the query string, which means shared review links and a functional back button — features native apps spend weeks approximating.
  • CDN caching. Your largest asset is the same bytes for every user in a region, cached at the edge, invalidated by filename.
  • Same-day updates across an org. Ship a fix and everyone has it without an update prompt, a version skew conversation, or an IT ticket.

For a product configurator, a data-heavy visual explainer, or an internal design review tool, these advantages are the product. Building native there would be a worse product, not just a slower one.

Where the model breaks

Be specific about the failure mode rather than saying "big scenes are slow":

  • Large scenes fail first on memory residency, not on draw calls. Mobile browsers will drop your context when VRAM pressure hits, and the recovery path — re-upload everything — produces a visible multi-second freeze. You find this at asset scale, not at complexity scale.
  • Long-running sessions fail first on leaks. A configurator open for eight hours in a warehouse accumulates texture handles from every variant the user tried. The browser tab becomes a memory eater and the OS kills it.
  • Heavy post-processing fails first on fill rate, and it is the exact case where browser profiling cannot help you — you are GPU-bound with no GPU counters.

Each of those has a web-side mitigation. None has a web-side fix, and knowing which one your project will hit first is worth more than a feature checklist.

Browser 3D Pre-Port Checklist

No scores. Just the characteristic, the practical read, and why.

CharacteristicPractical recommendationWhy
Scene complexityBrowser is viable below roughly 1–2 M triangles with batched submissions; above that, measure before committingDraw-call cost is controllable with instancing and merging; raw geometry throughput is limited by the device, and mobile GPUs vary by an order of magnitude
Session lengthBrowser if sessions are minutes; native if they are hoursDisposal bugs and context-loss recovery dominate long sessions, and both are stricter in the browser
Asset turnaroundBrowser wins outrightCDN invalidation plus same-day deploys is a genuine workflow advantage no installer matches
Target devicesBrowser if the target is unknown or broad; native if it is a fixed kiosk or workstationYou cannot control the GPU in the browser, so a fixed hardware target plays to native's strengths
Fidelity ceilingNative if you need virtual texturing, offline bakes, or heavy postNone of these have a production web equivalent today
Distribution reachBrowser wins outrightA link beats an installer for review, demo, and field use

What Would Change This Browser-vs-Native Assessment

Everything in this section is forward-looking inference, not observation. I'm marking it that way deliberately.

The assessment flips toward "parity" if:

  • WebGPU availability becomes broad and consistent, including on the mid-tier Android devices that currently fall back to WebGL2. Availability is improving; consistency of limits and features across vendors is the harder half.
  • Portable pipeline caches land. If a pipeline cache can persist across sessions and across a browser update, first-frame stutter becomes a solved problem instead of a mitigation exercise. This is the single change with the largest user-visible impact.
  • A real in-browser GPU profiling story arrives. Timestamp queries with stable vendor support and a DevTools surface that reads them would close the optimization loop that currently costs days.
  • A web-native asset pipeline matches DCC export fidelity — meaning material graphs and authoring constructs survive the round trip. Nothing I know of is close.

Until at least two of those land, treat the tooling gap as a fixed cost of the platform.

Conclusion

Startup Fortune's 31 October 2026 piece is right that the browser has become a capable home for 3D. It is wrong if you read it as a claim that the browser has caught up to native tooling. r187 is a checkpoint on a long, real curve — a revision number in a monthly cadence, not a threshold being crossed.

The distinction has budget consequences. Runtime credibility is why you can ship browser 3D. Tooling parity is what determines how much engineering you will spend after launch on profiling without GPU counters, asset pipelines that lose material graphs on export, first-frame stutter you cannot precompile away, and disposal discipline that has to be enforced by a tracker rather than by review.

Ship browser 3D where distribution is the product advantage — configurators, visual explainers, review tools, anything that benefits from being a URL. Budget honestly for the work the browser still cannot absorb, and pin your revision in the same commit as your benchmark so you know which numbers you are arguing about.

Further Reading

Share this post

More posts

Loading comments…