Skip to content
academia.sh

Lesson 06 / 19

Edge Rendering

Bringing rendering geographically closer to the user; converting distance into latency, the edge node's passthrough, combination, and full-production forms, the edge runtime's constraints, and the question of where the data sits.

Contents

The previous two techniques changed the response’s delivery form and the moment interactivity is gained; they left the distance between the server and the user as it was. The first lesson’s chain calculation assumed a 60-millisecond network round trip, and it was said that this line item does not shrink with a code change: it depends on the distance the signal has to cross.

This lesson takes up shortening that path: moving part or all of the rendering to a node that sits close to the user.

Distance Sets a Lower Bound

Light travels through fiber slower than through vacuum, at roughly 200,000 kilometers per second. This gives a lower bound that no software can beat.

// distance.mjs — converting geographic distance to latency and the round trips the edge gains
// Light travels through fiber at roughly 200,000 km/s: this is a lower bound,
// real paths are not straight lines, and processing time is added at every hop.
const SPEED = 200000; // km/s
const trip = (km) => (2 * km / SPEED) * 1000; // ms, round trip

console.log("distance (km)".padStart(14) + "one way (ms)".padStart(14) + "trip (ms)".padStart(11));
for (const km of [50, 500, 2500, 9000, 16000]) {
  console.log(String(km).padStart(14) + (trip(km) / 2).toFixed(1).padStart(14) +
    trip(km).toFixed(1).padStart(11));
}

// The number of round trips a page pays before first appearance:
// 1 transport handshake + 1 encryption handshake + 1 document request + 1 subresource trip.
const TRIP_COUNT = 4;
const SERVER_WORK = 40;  // ms, data source + template
const EDGE_WORK = 2;     // ms, the edge node's own work

const USER_TO_EDGE = 50;      // km
const USER_TO_ORIGIN = 9000;  // km

const originOnly = TRIP_COUNT * trip(USER_TO_ORIGIN) + SERVER_WORK;
const edgeHit = TRIP_COUNT * trip(USER_TO_EDGE) + EDGE_WORK;
const edgeMiss = edgeHit + trip(USER_TO_ORIGIN - USER_TO_EDGE) + SERVER_WORK;

console.log("\nscenario".padEnd(34) + "duration (ms)".padStart(15) + "relative to origin".padStart(20));
for (const [name, duration] of [
  ["origin server only", originOnly],
  ["edge, has a copy", edgeHit],
  ["edge, had to ask origin", edgeMiss],
]) {
  console.log(name.padEnd(34) + duration.toFixed(1).padStart(15) +
    (duration / originOnly).toFixed(2).padStart(20));
}
 distance (km)  one way (ms)  trip (ms)
            50           0.3        0.5
           500           2.5        5.0
          2500          12.5       25.0
          9000          45.0       90.0
         16000          80.0      160.0

scenario                           duration (ms)  relative to origin
origin server only                          400.0                1.00
edge, has a copy                              4.0                0.01
edge, had to ask origin                     133.5                0.33

The upper table is a design constraint on its own: a single round trip to a server nine thousand kilometers away cannot come in under 90 milliseconds. This number is optimistic; real paths are not straight lines, and processing time is added at every hop.

The lower table shows the round trips’ multiplying effect. If a page’s appearance needs four round trips, this comes to 360 milliseconds on a distant server, and the data source’s 40-millisecond work drops to less than a tenth of the total. Speeding up the server’s work is wasted effort here; what needs shrinking is the length of the round trips.

The third row also states the edge’s limit: if the edge node cannot satisfy the request and has to ask the origin, the handshakes get closer, but the content still comes from far away, and the duration drops to a third of the origin’s, not to a hundredth.

The Edge Node’s Three Forms of Work

Having a node at the edge says nothing on its own; what decides is what that node does. Three forms can be measured separately. In the origin server below, geographic distance is modeled as a fixed delay added before the response; it is not a real network measurement.

// origin-server.mjs — the distant origin server
// Geographic distance is modeled as a fixed delay added before the response.
import { createServer } from "node:http";

const DISTANCE = 80;   // ms, cost of a round trip to the origin
const DATA_WORK = 40;  // ms, reading from the data source

const wait = (ms) => new Promise((c) => setTimeout(c, ms));

const SHELL_CONTENT = '<h1>North Slope Measurement Station</h1>' +
  '<ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li><li>08:00 -2.1 C</li></ul>';

const send = (response, body) => {
  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
};

createServer(async (request, response) => {
  response.sendDate = false;
  await wait(DISTANCE);
  if (request.url === "/page") {
    await wait(DATA_WORK);
    send(response, `<!doctype html><html lang="en"><body>${SHELL_CONTENT}` +
      `<p>Operator: (produced at origin)</p></body></html>\n`);
  } else if (request.url === "/section/personal") {
    await wait(5); // small, person-specific section
    send(response, "<p>Operator: A. Carter</p>");
  } else if (request.url === "/data/measurements") {
    await wait(DATA_WORK);
    response.setHeader("Content-Type", "application/json");
    const body = JSON.stringify([
      { time: "06:00", temperature: -4.2 },
      { time: "07:00", temperature: -3.8 },
      { time: "08:00", temperature: -2.1 },
    ]);
    response.setHeader("Content-Length", Buffer.byteLength(body));
    response.writeHead(200).end(body);
  } else {
    response.writeHead(404).end();
  }
}).listen(8176, "127.0.0.1", () => console.log("origin: 127.0.0.1:8176"));
// edge-server.mjs — a node close to the user, able to run code, at the edge
// Access to the origin server is over 8176; the distance to the user is ignored.
import { createServer } from "node:http";

const ORIGIN = "http://127.0.0.1:8176";

// What the edge holds: the shell from a build output, measurements from the origin.
const SHELL = (content, personal) =>
  `<!doctype html><html lang="en"><body>${content}${personal}</body></html>\n`;
const HEADING = "<h1>North Slope Measurement Station</h1>";
let measurementsCopy = null;

const send = (response, body) => {
  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
};

const list = (measurements) =>
  "<ul>" + measurements.map((m) => `<li>${m.time} ${m.temperature} C</li>`).join("") + "</ul>";

createServer(async (request, response) => {
  response.sendDate = false;
  if (request.url === "/passthrough") {
    // The edge only passes through: all the work happens at the origin.
    const originResponse = await fetch(`${ORIGIN}/page`);
    send(response, await originResponse.text());
  } else if (request.url === "/combine") {
    // The shell is ready at the edge; only the small personal section is requested from origin.
    const section = await (await fetch(`${ORIGIN}/section/personal`)).text();
    send(response, SHELL(HEADING + list(measurementsCopy ?? []), section));
  } else if (request.url === "/at-edge") {
    // The whole page is produced from the edge's own copy; the origin is never asked.
    send(response, SHELL(HEADING + list(measurementsCopy ?? []), "<p>Operator: (general)</p>"));
  } else if (request.url === "/refresh") {
    // Refreshes the edge's data copy from the origin.
    measurementsCopy = await (await fetch(`${ORIGIN}/data/measurements`)).json();
    send(response, `<p>${measurementsCopy.length} measurements received</p>\n`);
  } else {
    response.writeHead(404).end();
  }
}).listen(8177, "127.0.0.1", () => console.log("edge: 127.0.0.1:8177"));

The script below runs in the same directory as these two files. Ports 8176 and 8177 are chosen arbitrarily and must be free; if they are in use, change them in all three files.

#!/usr/bin/env bash
# Starts two servers, measures three divisions of work, then stops them.
node origin-server.mjs > /dev/null &
origin=$!
node edge-server.mjs > /dev/null &
edge=$!
sleep 1

FORMAT='first byte %{time_starttransfer} s   body %{size_download} B\n'

printf '%-38s' "0) edge refreshing its copy"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/refresh

printf '%-38s' "1) direct to origin server"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8176/page

printf '%-38s' "2) edge only passing through"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/passthrough

printf '%-38s' "3) edge combining"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/combine

printf '%-38s' "4) edge producing entirely itself"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/at-edge

echo "--- body produced at the edge ---"
curl -sS http://127.0.0.1:8177/at-edge

kill "$origin" "$edge"
0) edge refreshing its copy           first byte 0.147992 s   body 31 B
1) direct to origin server            first byte 0.125484 s   body 201 B
2) edge only passing through          first byte 0.130341 s   body 201 B
3) edge combining                     first byte 0.094478 s   body 190 B
4) edge producing entirely itself     first byte 0.000806 s   body 190 B
--- body produced at the edge ---
<!doctype html><html lang="en"><body><h1>North Slope Measurement Station</h1><ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li><li>08:00 -2.1 C</li></ul><p>Operator: (general)</p></body></html>

The zeroth row is not part of the measurement; it is the preparation step where the edge fills its data copy. Duration fields depend on the machine, and the interpretation rests on the ratios.

The second row is the edge used wrong. A node that only passes through adds its own work to the origin’s duration and is slower than direct access — roughly four percent in the measured run. The gap’s size varies from run to run, but not its sign: a node in between carries no gain by itself; the gain comes from the work it takes on.

The third row is division of labor. The shell and the measurement list are ready at the edge; only the small, person-specific section is requested from the origin. The duration drops to roughly three-quarters of direct access. The gain does not come from never going to the origin — it comes from asking the origin for little.

The fourth row is full production at the edge. The origin is never asked; the duration shrinks by two orders of magnitude — roughly a hundred and fifty times in the measured run. This condition requires everything needed to produce the page to be present at the edge.

The Constraints of the Edge Runtime

Running code at the edge is not the same as running code on the origin server. The constraints arise from what the edge is by definition: many tenants run side by side on many nodes, each with few resources.

The process is not long-lived. In-memory state that persists outside a request cannot be assumed; a counter kept on one node is not present on another. State is held either in the request itself or in a shared store.

The processing and memory budget is tight. Heavy template processing, a large dependency tree, or a long-running computation does not suit the edge. Code meant to run at the edge is smaller than code running on the origin server, and this is a limit, not a preference.

The first call’s cost is separate. A startup duration arises when a piece of code that has not been called for a while gets reloaded. The milliseconds the edge gains can be on the same order as this duration; this is why rarely called paths do not get faster by being moved to the edge.

The environment is not the same from node to node. Code running at the edge may not have all the capabilities of code on the origin server. A file system, long-lived connections, and some libraries may be absent, and code is written with that assumption.

The Real Question Is Where the Data Sits

The gain in the measurement’s fourth row arose from the edge carrying a copy of the measurement list. This copy was fetched from the origin in the zeroth step, and it starts aging from the moment it was fetched.

This gives edge rendering’s real rule: rendering can move to the edge only as far as its data can move with it. If the code moved to the edge asks the origin for data on every request, the distance is paid again; the third row measures this. The full gain is only realized when the data is also at the edge.

Moving data to the edge takes three forms, each with its own cost. Rarely changing data is copied; its cost is freshness, and the window calculation from the Incremental Regeneration lesson holds here too. Small, user-bound data travels in the request itself; its cost is that it must be signed so it cannot be tampered with. Everything else stays at the origin, paying the distance for those sections.

This three-way split requires the page to be divided into sections. On the station page, the shell and the measurement list can be copied, the operator’s name can travel in the request, and edit permissions are checked at the origin. A page that is a single piece cannot be split this way, and the entire page follows its most restrictive section’s rule.

Summary

  • Geographic distance sets a lower bound on latency: for nine thousand kilometers, a single round trip does not come in under 90 milliseconds, and it is multiplied by the round trip count paid per page.
  • Having a node at the edge is not a gain on its own; a node that only passes through is slower than direct access.
  • An edge that asks the origin for only a small piece brings the duration down to three-quarters of direct access; an edge that never asks brings it under one percent.
  • The edge runtime does not assume long-lived memory state, works with a tight processing and memory budget, incurs a startup duration on the first call, and has narrower capabilities than the origin server’s.
  • Rendering can move to the edge only as far as its data can move; in a design where the page is not split into sections, the whole page follows the most restrictive section’s rule.

Next Step

Six models have been measured separately, and each came out ahead in a specific situation. What remains is tying these measurements to a single decision. The North Slope site has seven distinct page types, and there is no reason for all of them to share a model: the landing page never changes, the measurement list changes every minute, the archive pages number in the thousands, the panel is personal. The next lesson places this inventory on three axes — content freshness, personalization, and scale — and builds a decision rule that derives the model for each page from these axes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close