Backend architecture

A rotating WebGL globe for a Next.js hero: real data, travelling arcs and four guards

A cobe globe with dotted continents, a marker on every relay location and arcs between them, fed by real database rows: how it is built in Next.js, how the data reaches it, and four guards that each answer one specific failure.

Next.js, React and WebGL

The hero of a client's marketing site — a Lightning Network service — has a globe: dotted continents, a slow rotation, a marker on every relay location, and arcs of light travelling between them. Nothing on the page depends on it, but it is driven by real database rows, and a point on the globe is a claim to the visitor.

It exists twice: as a React client component on the Next.js site, with tooltips and drag-to-rotate, and as a decorative plain ES module on a server-rendered page of the same service. My build guide for the second records the four guards; where the React version differs, I say so.

Why cobe

cobe (MIT, about 7 KB) draws what a hero globe needs: a dot-matrix world map, an atmosphere, rotation and markers. Its only dependency, phenomenon, adds about 6 KB. The port vendors both, because that project builds offline behind mirrors. cobe has no notion of a link between two points, so the arcs live on a second, plain 2D canvas laid over the sphere and redrawn every frame.

The component in Next.js

The file starts with "use client", and an isClient flag renders a placeholder until mount. Then one effect creates the globe:

try {
    globeInstanceRef.current = createGlobe(canvasRef.current, {
        ...GLOBE_CONFIG,
        width: widthRef.current * 2,
        height: widthRef.current * 2,
        onRender,
    });
} catch (error) {
    console.warn("The globe needs WebGL, which this browser does not offer:", error);
    window.removeEventListener("resize", onResize);
    onUnavailable?.();
    return;
}

That try is not ceremony. A browser with WebGL disabled hands cobe no context, and cobe throws; uncaught, that unmounted the whole page. Now the page hears onUnavailable and drops the globe section while search and cards keep working.

The config passes markers: []: each relay is a positioned div moved every frame, so it can be hovered and tapped. Framer Motion animates the rest of the page; cobe brings its own loop.

The data contract

The browser never calls the backend. It asks a route handler, /api/nodes/, which signs each backend request with an HMAC computed on the server from an environment secret, and follows the pagination so the globe gets every marker at once:

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// … inside GET():
while (url && page < MAX_NODE_PAGES) {
    const data: BackendNodesPage = await backendRequest<BackendNodesPage>(url);
    nodes.push(...(data.results ?? []));
    count = data.count ?? nodes.length;
    timestamp = data.timestamp ?? timestamp;
    url = data.next;
    page += 1;
}

return NextResponse.json(
    {count, results: nodes, timestamp, truncated: Boolean(url)},
    {headers: {"Cache-Control": "no-store"}}
);

On the client, toNodeData maps the rows to the component's shape. If the request fails, the globe turns without markers.

The port receives a JSON list of lat, lng and title in a data attribute, under three rules that each fail silently:

  1. Cap at 64. cobe's shader declares a vec4 uniform array of 64*2; marker 65 is dropped by the GPU with no error. Cut the list on the server, where the limit is visible.
  2. Send only what should be drawn: active, not-deleted rows.
  3. Escape for the attribute. A raw " closes it. Keep autoescaping on, JSON or not.

An attribute because a strict CSP needs a nonce for inline script, while an attribute only needs escaping. And if the page is cached, a new point waits for expiry — the most confusing part of this work.

The animation loop

Two loops run together. cobe calls onRender(state) each frame and the component writes state.phi back. A second requestAnimationFrame loop projects every relay to the screen and redraws the arcs from those positions, so each line starts and ends on its dots. The React version turns a fixed step per frame; the port turns per millisecond (ROTATION_PER_MS = 0.0001), so a faster display doesn't spin it faster.

An arc grows from one city to another, holds, then fades; the React version keeps at most five alive, the port four. Three details came from watching it fail:

const facing = positions.filter((pos) => pos.z > ARC_SETTINGS.limbFade);
if (arcsRef.current.length < ARC_SETTINGS.maxArcs && facing.length >= 2) {
    const fromIndex = Math.floor(Math.random() * facing.length);
    let toIndex = Math.floor(Math.random() * (facing.length - 1));
    if (toIndex >= fromIndex) toIndex += 1;
    // … pick the arc's colour, then:
    arcsRef.current.push({
        fromId: facing[fromIndex].node.id,
        toId: facing[toIndex].node.id,
        born: now,
        rgb,
    });
}
  • Ends come from the near face only. Picking from the whole list left about three quarters of the arcs born behind the globe, never drawn.
  • An arc dims as its ends near the limb, rather than vanishing between two frames.
  • The curve bows away from the disc's centre, so it passes over the globe, not through it, walked in 48 steps because a quadratic curve has no partial draw.

The four guards

1. Take the WebGL context before cobe does

cobe picks a context type by calling getContext("webgl2"), then "webgl", and only then asks phenomenon for that type with its attributes. A canvas has one context for life, so those attributes are dropped; passing preserveDrawingBuffer through cobe does nothing. The port claims the context first:

function claimContext(canvas) {
    const attributes = {
        alpha: true,
        antialias: true,
        depth: false,
        stencil: false,
        preserveDrawingBuffer: true,
    };
    try {
        return (
            canvas.getContext('webgl2', attributes) ||
            canvas.getContext('webgl', attributes) ||
            canvas.getContext('experimental-webgl', attributes)
        );
    } catch (error) {
        return null;
    }
}

cobe's probe finds it and keeps it. Without a preserved buffer the canvas can't be read after the frame, and guard 2 would be dead code. The probe order and the 64 cap come from a built bundle, not a documented API, so recheck both after upgrading.

2. Never show a white square

On some machines an empty WebGL canvas composites white, so the globe starts at opacity: 0. Counting frames can't reveal it safely: a canvas can be reported drawn while still blank. The port copies the sphere into a 24×24 canvas and reads it back — after 4 frames, since the map arrives through an Image; at most every 150 ms, since it is a GPU readback; and after 5 seconds of white it hides and destroys the globe. looksBlank says "blank" only when over 90% of pixels are opaque and over 90% of those are near-white; an unreadable buffer counts as fine, because hiding the globe on a bad reading is the worse trade.

The React component just fades in after 100 ms; when that timer was gated on a flag another effect could clear, the globe sometimes stayed invisible for good.

3. Validate every coordinate

A bad coordinate doesn't throw; it becomes NaN and quietly breaks every arc to that point. Both versions drop rows whose lat or lng is not finite or out of range. And cobe's dots are a real map, so an invented point lands in an ocean.

4. Don't start what nobody can see

Below 1200px the port's hero column is display: none, so the container measures 0×0, and starting would hold a WebGL context and a render loop for nothing:

function ensureGlobe() {
    if (created || failed || width <= 1 || height <= 1) {
        return;
    }
    try {
        created = start();
    } catch (error) {
        failed = true;
    }
}

A resize listener calls it again, so rotating a phone brings the globe up. The React cousin: with no relays, it skips the loop that used to re-render the component 60 times a second.

Accessibility and reduced motion

The port is aria-hidden, with no pointer events and no tooltip. The React version is interactive, so only its arc canvas is aria-hidden. Two lessons came from it: on wide screens the globe overflowed upwards and swallowed clicks meant for the search input, so the content above now sits higher in z-index; and on touch, a vertical swipe goes to the page, not the globe, which is up to 1200px tall.

Under prefers-reduced-motion: reduce the port stops rotation and arcs; the sphere stays, holding still:

const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');

// … inside cobe's onRender:
if (!reduced.matches) {
    phi += ROTATION_PER_MS * delta;
    if (now - lastSpawn > SPAWN_EVERY) {
        lastSpawn = now;
        spawnArc(now);
    }
}
state.phi = phi;

The React component checks that query only before spawning an arc, so it keeps rotating. That is a gap, and the port shows the fix.

Performance notes

  • About 13 KB of third-party code.
  • The readback is throttled, works on a 24×24 copy and stops once the globe shows.
  • The arc canvas caps the pixel ratio at 2.
  • Hit-tests read positions from a ref, so listeners aren't re-registered every frame.
  • DOM markers cost a React render per frame; that is the price of hover.

Checking it

requestAnimationFrame doesn't run in a hidden tab, so there a working globe looks dead. The contract, though, is testable without WebGL: server-side tests check active rows, the 64 cap, finite coordinates, an escaped title, and [] for an empty table.