← All posts

How the home page is built

A live 3D network, other visitors' cursors in real time and a hard $2 spending cap: the choices behind lesas.dev's home page, and what each costs.

5 min read

What I wanted from it#

I rebuilt this site with three goals, in this order. It had to be fast on any device, including a mid-range phone on a train. It had to be cheap enough to forget about: the AWS side averages around twenty cents a month. And it had to be safe to leave running, with hard caps on anything that costs money.

Each goal ended up deciding something concrete. This post walks through those decisions on the home page, roughly from the browser outwards.

Server first, 3D after#

Pages are React Server Components on Next.js 16, so each one is built on the server and arrives as finished HTML. The text you're reading appeared without waiting for any JavaScript, and search engines and AI tools can read it too, since many of them don't run JavaScript at all. Entrance animations are CSS for the same reason: they play as soon as the page arrives, rather than after React has loaded.

The home page's 3D network is the heaviest part of the site, so it loads separately, once the text is on screen and the browser has nothing else to do. On a slow connection you can start reading straight away while it downloads in the background. That helps with search too: Google ranks pages partly on how quickly their main content appears.

The network in four draw calls#

Each time the CPU asks the GPU to draw something is a draw call, and every call costs the CPU a fixed amount of work before anything appears: three.js places the object and sets up its shader, and the browser checks the call before passing it on. Drawn one object at a time, the largest network, 200 nodes and up to 150 connections, would take about 350 draw calls a frame. That's a lot of overhead to repeat 60 times a second, and on a mid-range phone it can be enough to make the animation stutter.

Instead, each kind of thing is drawn in a single call. Every node's position goes into one array that's handed over once, and the GPU, which is built to draw thousands of points at once with its many thousands of cores, draws them all. That makes four draw calls: other visitors' cursor glows (yes, I have implemented realtime features, if someone else is on the home page you will see them), your cursor's glow, every connection and every node. It stays at four however many nodes there are: a bigger network means a bigger array, not more calls. And nothing in the animation loop creates new memory, so the browser never has to pause mid-animation to clean it up, a common cause of small stutters.

Draw call 1 of 4. Other visitors' cursor glows, each in their own colour: one sprite each, all in one call.

Physics at a fixed 60 Hz#

The simplest loop moves every node once per frame, but screens refresh at different rates: 60 times a second on most, 120 on many newer phones and laptops. The physics rules are written per step (each step, a node keeps 95% of its speed), so stepped every frame on a 120 Hz screen, nodes would lose speed twice as fast and snap back to their paths sooner. The network would feel different on every screen, and the CPU would do twice the physics work each second.

So the physics steps a fixed 60 times a second, whatever the screen. The motion feels the same everywhere, and a 120 Hz screen gets half the physics work. If the page stalls, the missed steps pile up. Replaying them all at once would make the next frame slow too, and the backlog would snowball, so it catches up at most four steps a frame and drops the rest.

// Catch up in fixed 1/60 s steps, at most four per frame
acc = Math.min(acc + delta, STEP * MAX_STEPS_PER_FRAME);
while (acc >= STEP) {
  physicsStep(inputs, mouse, remotes, colliders);
  acc -= STEP;
}
Point at the nodes to push them. Each is pulled back towards where its drift path has it (the faint lines). Your screen: … frames a second. The physics: … steps.

Finding connections with a grid#

To decide which nodes to connect, the network has to find every pair that's close enough. Checking every pair of 200 nodes takes 19,900 comparisons, and that grows fast: double the nodes and it's four times the work. Instead, nodes are sorted into a grid of cells as wide as the longest connection, so any two nodes close enough to connect are always in the same cell or neighbouring ones. Each node is compared only with its own cell and the eight around it: about 1,600 comparisons, around a twelfth of the work.

Cells as wide as the longest connection. A node's links can only be in its own cell or the eight around it, so this one is compared with … nodes, not all …. Across the network, that's … comparisons instead of ….

Keeping the bill near zero#

The home page shows other visitors' cursors live using AWS AppSync, a service that passes each cursor's position on to everyone else on the page. It charges for every message and for every minute someone's connected, and a moving cursor sends a steady stream of messages. A busy day or a misbehaving script could multiply that, which makes real-time traffic the likeliest way for a hobby site to run up a surprise bill. So the cursors switch themselves off before that can happen.

A hard spending cutoff#

A small Lambda function deletes the AppSync API keys, which switches live cursors off for everyone, in three situations: when a per-minute alarm trips on message, connection or subscription counts; when a check every 15 minutes finds the month's AppSync spend past $2; or when an AWS Budgets alert fires as a backstop. Turning cursors back on is a deliberate, manual step.

Measuring it#

Two sets of numbers I keep an eye on. Lighthouse, Google's tool for checking a page's speed, accessibility, good practice and search-friendliness, scores the home page out of 100. On desktop:

CategoryScore
Performance100
Accessibility100
Best practices100
SEO100

And how long the home page's network takes to work out each frame, physics and connections included. Each row is the size of network that kind of device gets, all timed on my PC. To run smoothly at 60 frames a second, the browser has 16,667 µs (millionths of a second) to prepare each frame, and even the largest network uses under a quarter of one percent of that:

Device tierNodesPer frame
Phone11015.8 µs
Laptop15025.8 µs
Desktop20040.6 µs