Skip to content
Book a call (opens Calendly in a new tab)
Build notes · 4 min read

Getting a 3D globe portfolio fast on mobile

My new portfolio opens on a WebGL globe. The first version cost a throttled phone seven seconds of main-thread work. Here's what fixed it — and what I chose not to fix.

On this page
  1. 1. Don't compute the map in the browser
  2. 2. Don't re-run the library every frame
  3. 3. Don't make text wait for JavaScript
  4. 4. Show a poster, load the rest later
  5. What I didn't fix

This site opens on a dot-matrix globe: the countries I've worked in light up, and arcs fly from Dhaka to client cities. It's built with globe.gl, which wraps three.js. It looked right on day one. On a throttled phone it was a disaster.

These are the fixes that mattered, with the numbers before and after. All scores are Lighthouse mobile, which simulates a mid-range phone on slow 4G.

1. Don't compute the map in the browser

The dot-matrix landmass came from globe.gl's hexPolygonsData: give it country outlines, and it fills each one with hexagonal cells. That's an expensive geometry job — and it was running on every visitor's phone, every visit.

Cost: about 7 seconds of main-thread work on a throttled phone. Total blocking time on the home page was 6.7 s.

The dots never change, so I moved that work to build time. A small Node script uses h3-js — the same hexagon grid globe.gl uses — to list every land cell once:

import { cellToLatLng, polygonToCells } from "h3-js";

const cells = new Set();
for (const { geometry } of features) {
  const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates;
  for (const polygon of polygons) {
    polygonToCells(polygon, 3, true).forEach((c) => cells.add(c));
  }
}
// → 11,216 cells, saved as a flat [lat, lng, …] array

The browser now downloads a 55 KB file of points and draws them as a single point cloud. It also no longer downloads the 250 KB country outlines at all.

Total blocking time went from 6.7 s to about 0.5 s.

2. Don't re-run the library every frame

During the intro, the countries I've worked in fade from grey to lime. My first version updated the colour through globe.gl's API on every animation frame. That looks harmless, but each call re-runs the library's whole update cycle.

Cost: about 1.9 s of blocking time, just for a colour fade.

The fix is to change the one material directly:

const material = workedDots.__threeObjParticles?.material;
material.color.setRGB(r / 255, g / 255, b / 255);
material.opacity = a;

Reaching into a library's internals is a trade-off — it can break on an upgrade — so it's wrapped in one function with a fallback.

3. Don't make text wait for JavaScript

The headline used GSAP's SplitText to animate letter by letter. That meant the page's most important text — the thing Lighthouse measures as Largest Contentful Paint — couldn't appear until GSAP had downloaded and run.

I rebuilt every above-the-fold entrance in plain CSS. The words are split into spans when the site is built, and a keyframe animation starts from the very first frame:

[data-intro="text"] .reveal-word > span {
  animation: intro-rise 1.1s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-delay: calc(var(--d, 0ms) + var(--i, 0) * 50ms);
}

GSAP still drives the globe and the scroll effects, but it now loads after first paint. The home page's first-load JavaScript dropped from 284 KB to 234 KB, and inner pages went from the low 90s to 96–98 on the live site.

4. Show a poster, load the rest later

Project cards show the client's site in a browser frame, and hovering scrolls through the whole page. That needs a tall, full-page screenshot — 100–190 KB each.

Now each card first shows a poster: just the top screen of the capture, 22–69 KB, cropped so it's pixel-identical. The tall image loads quietly after the page has finished loading, and only on devices with a mouse. Phones never download it, because touch never scrolls it.

The /work page went from 87 to 98, and a phone scrolling the full page saves about 1.5 MB.

What I didn't fix

The home page still scores lower than the rest — 79 on a good run. globe.gl and three.js are about 500 KB of compressed JavaScript, and parsing that on a slow phone takes time, however late it loads.

I could replace the 3D globe on phones with a flat SVG map and get the home page into the 90s. For now I've kept the globe: it's the first thing people see, and it tells the "clients worldwide" story better than a sentence can. With real visitor data, I may change my mind.

That's the honest version of "performance": not a perfect score everywhere, but knowing exactly what each choice costs.