ADR: A rendering service beside the API
Status: accepted, 2026-10-08, in 1.0.3.104, the version that puts the service in front of visitors. It came into the repository in 1.0.3.100, could not roll until 1.0.3.101, was measured alone in Docker and given its numbers in 1.0.3.102, and got its own site and roll in 1.0.3.103. The plan moved to B2 first, because the memory read said the service did not fit on B1. Each step's numbers are in an addendum below, and the before and after on both domains in the last, with the fix 1.0.3.105 made.
In plain words
Until now the site's pages were drawn by the visitor's browser. The HTML that arrived was the frame and, on the bare address only, the landing page the build drew once (ADR: The landing page rendered at build time). Every other address arrived empty, and the browser showed nothing of it until a 300 KB script had arrived, run, and asked the API for the data. This record adds a rendering service: a small Node program beside the API that draws any page on each request, with the data already in it, and sends the HTML. The browser paints that HTML at once, and React then takes it over instead of drawing it again.
What that is worth: a visitor sees the inventory, a vehicle or a document as soon as the HTML lands instead of after the script and two API round trips, a crawler or an AI tool that runs no script reads the real page, and the API stays what it is, a service that answers in JSON and draws nothing.
Context
Steve, 8 October: "we have extra claude this week how hard would it be to set up react on a server", then "the code for B1 and we'll see the 13$ a month is nothing if needed, but my gut says we won't need it", "when we can in the future cloudflare", and "I want to learn this technology and make the site as responsive as possible."
ADR-092 left two things in place for this: every component the landing page uses draws without a browser, held by a test, and mount.tsx already takes over a drawn page with hydrateRoot. It also left the rule this record keeps: the renderer is never code inside the API (ServerRenderingReadinessTests).
Decision
1. One function behind any door. render/render.ts exports createRenderer, which gives one function: render(url, headers, apiOrigin) returns a standard Response. It uses web streams, fetch and React's edge renderer (react-dom/server.edge), and no Node API, so the same function runs on App Service under Node today and in a Cloudflare Worker when the domain moves there. render/server.mjs is the App Service door: Node's own http module, about 150 lines, no framework. Hono or Express would add a package to keep current and nothing this door uses; the door's one job is to hand a request to render and stream the answer back.
export type OwnBuild = { version: string; commit: string };
export const RENDERED_HEADER = 'X-Yard-Rendered';
export type Render = (url: string, headers: Headers, apiOrigin: string) => Promise<Response>;
export function createRenderer(
own: OwnBuild,
deadlineMs = 2_500,
read: typeof fetch = fetch
): Render {
const pageAt = servedPages(read);
return async (url, headers, apiOrigin) => {
const page = await pageAt(apiOrigin);
if (!page) {
return new Response('The API is not answering, so there is no page to send.', {
status: 503,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
[RENDERED_HEADER]: 'no-api',
'Retry-After': '10',
},
});
}
if (page.version !== own.version || page.commit !== own.commit) {
return asServed(page, `other-build ${page.version}`);
}
const search = new URL(url).search.replace(/^\?/, '');
const cookie = headers.get('cookie');
const load = loadFirst(search, cookie, apiOrigin, deadlineMs, read);
return new Response(streamPage(page, load), { status: 200, headers: pageHeaders(cookie) });
};
}
export const SESSION_COOKIE = 'theyard_session';
export const EDGE_RULE = 'public, max-age=10, stale-while-revalidate=20';
export const EDGE_VARY = `query,cookie=${SESSION_COOKIE}`;
export function pageHeaders(cookie: string | null): Record<string, string> {
const anonymous = cookie === null;
return {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': anonymous ? 'no-cache' : 'private, no-cache',
Vary: 'Cookie',
'Netlify-CDN-Cache-Control': anonymous ? EDGE_RULE : 'private, no-store',
'Netlify-Vary': EDGE_VARY,
[RENDERED_HEADER]: 'drawn',
};
}
function asServed(page: ServedPage, why: string): Response {
return new Response(page.html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
[RENDERED_HEADER]: why,
},
});
}
Live from render/render.ts, region the-renderer, read from this build at 1d6b50b.
2. The page comes from the API, and the service draws only for the build it was made from. The service reads index.html from the API it draws for, so the page always names the bundle that API serves, and it reads /api/version beside it. React in that bundle takes the markup over only if the service drew it from the same code, so while the API runs another build (the minutes of a roll) the service sends the API's page untouched and the browser draws it, as the site did before. Both reads are kept for fifteen seconds per site.
3. A loader per view, all at once, under one deadline. render/loaders.ts reads what an address needs before it is drawn: who is signed in, the build for the footer, the first page of the list and its filter options on any inventory or vehicle address, the vehicle a vehicle's address names, and the document a document's address names, rendered here with the same Markdown code the browser uses. The reads run together under a 2.5 second deadline. A read that fails or runs past it is left out, and that view draws its loading state, which the browser then fills as it always did. Admin and the account page draw in the browser behind the frame the service drew (useInTheBrowser).
4. The first load travels in the page. What the loaders read goes into the page as a JSON script beside #root, with every < written as its escape so no vehicle text can end the script early. mount.tsx reads it, and every hook that used to start empty starts from it: the address, the list, the vehicle, the account, the build and the clock. That is what makes the browser's first draw match the service's draw exactly.
5. Same markup on both sides, or React throws the page away. Four things differ between a server and a browser, and each has an answer:
- The clock: the service writes the time it drew at, and the browser's first draw counts down from that time, then ticks.
- The time zone: a server does not know the viewer's, so an auction's start and end stamps say UTC until the page is the browser's, then the viewer's zone. The stamp names its zone both times.
- The window's width:
useMediaQuery answers "does not match" on the server and while the page is taken over, then the window's answer, so a vehicle's header countdown joins on a desk a moment after the page paints.
- The document window: drawn open on the server, so it paints at once, and opened again as a modal once the page is live, so the backdrop and Escape behave as before.
6. The top of the page leaves at once. The service sends everything above #root before any API read has answered, so the browser starts on the stylesheet, the font and the bundle while the reads are out. React's own stream follows, then the first load, then the rest of the page. On a stand-in API in this session's harness that first byte left in 3 ms with the reads still out; the measured numbers go below once the service runs in Docker and then live.
function streamPage(page: ServedPage, load: Promise<Awaited<ReturnType<typeof loadFirst>>>) {
const text = new TextEncoder();
return new ReadableStream<Uint8Array>({
async start(out) {
out.enqueue(text.encode(page.before));
let opened = false;
try {
const first = await load;
const drawing = await drawForTheService(first, (error) => console.error('draw:', error));
out.enqueue(text.encode('\n <div id="root" data-drawn="server">'));
opened = true;
const reader = drawing.getReader();
for (let part = await reader.read(); !part.done; part = await reader.read()) {
out.enqueue(part.value);
}
out.enqueue(text.encode(`</div>\n ${firstLoadElement(first)}\n `));
} catch (error) {
console.error('page:', error);
out.enqueue(text.encode(opened ? '</div>\n ' : '\n <div id="root"></div>\n '));
}
out.enqueue(text.encode(page.after));
out.close();
},
});
}
Live from render/render.ts, region the-stream, read from this build at 1d6b50b.
7. It reaches the API only. The service reads what any browser can read, from the API's public addresses, forwarding the visitor's cookie and nothing else, with the self mark on its agent so the Site activity card counts the visit once. It names no store and opens no connection of its own; render/render.test.ts holds both.
8. One service for both sites. The service draws for each site by path: /site/sql and /site/cosmos, each read from its own site's API. One Node process for both keeps the third container on the plan as small as it can be.
What happens when the service cannot draw
The service never answers 5xx for a page it could not draw. A failed read leaves that view to the browser. A component that throws while drawing leaves #root empty with no first load, and the browser draws the page from nothing, as it did before this record. The one answer that is not a page is an API that does not answer at all: a 503, which the API's own address would answer too.
When the service itself is down, its container restarting or rolling, App Service answers for it, and the edge has no way to try the API instead. Until the edge moves to a Cloudflare Worker, which can catch that answer and fetch the API's page in its place, the way round is the address the API still serves every page from: /index.html with any query draws in the browser as it always did. The decision about where the edge sends pages, and its test, is the version that does it.
What was rejected
- Drawing inside the .NET host, by a Node process in its container or by templates in the host: ADR-092 rejected it, and nothing here changes why.
- A framework (Next.js, Remix): it would own the routing, the build and the server, and this site's routing is the address bar's query, held by its own hooks and tests. The service is one function over the app that already exists.
- The page built into the service's image: the service would draw into a page naming a bundle the API might no longer serve after the next roll. It reads the API's page instead.
- Streaming the data through Suspense: React streams markup for a slow part of the page, but the data the browser needs to take that part over has to arrive in the page too, and React 19 has no stable way to send it without a framework. The deadline in decision 3 gives the same outcome for this site, the frame first and the slow view after, with the browser asking for what the service did not wait for.
Addendum, 2026-10-08: the first push did not roll
1.0.3.100 passed its gate on both stores and was pushed at 12:03 CDT, and neither site rolled. Deploy #284 stopped at Build and push after 27 seconds, and Deploy Cosmos waited for an image that never came. The cause was this record's own test: src/app/drawServer.test.ts read its vehicles from data/vehicles.json, the build type-checks every file under src/, tests included, and the image's frontend stage copies src/ and public/ and nothing else. On a developer's machine and in CI, which hold the whole repository, it built. The same build on Steve's machine stopped at the same line, TS2307: Cannot find module '../../data/vehicles.json' (ssrlane-render-docker.log). In 1.0.3.101 the test writes its vehicles out, and DockerBuildInputsTests fails any file under src/ that imports from outside what the stage copies. The sites served 1.0.3.99 throughout.
Addendum, 2026-10-08: measured alone in Docker, before Azure
render/Dockerfile builds the service in two stages: the site's own sources built with the site's own config, then Node and that build alone, with no node_modules, no sources and no package manager. The image is 62.4 MB beside the API's 208.1 MB.
It was measured on Steve's machine at 13:10 to 13:15 CDT, against the 1.0.3.101 checkout (ssrlane-render-docker.log). The API ran in a container of its own with the scratch SQLite store, the service in another at --cpus=1 and a 512 MB limit, both images built with the same stamp so the service drew for that API. Each view was asked thirty times, one after another:
The first byte is the top of the page, sent before any API read has answered, which is decision 6 at work. The drawn #root time includes the API's own reads inside Docker.
Memory. 32.9 MiB at start, 51.6 MiB idle after every view had been drawn, 126.5 MiB at the median and 135.5 MiB at the peak under ten inventory pages a second, and 120.2 MiB thirty seconds after.
The limit, said plainly. Ten inventory pages a second is more than one core draws. All 600 requests answered, but the processor read 96 to 104 per cent throughout, and requests queued behind each other: the whole page took 20.7 s at the median and 30.5 s at the 95th percentile. One inventory page is about 100 ms of drawing, so one core draws about ten a second and no more. The site's traffic is a small fraction of that. The step that would raise it is the edge keeping an anonymous visitor's page for a few seconds, the next item in the order this record set.
The memory gate. The plan's memory over the 24 hours to 10:54 CDT read 91 per cent at the median, which is 1,631 MB of B1's 1,792 (mentor\logs\ssrlane-planread24), and the gate was the plan's median plus the service's measured peak at or under 90 per cent. On B1 that is 1,767 MB, or 98.6 per cent, so it did not fit, and the plan was over the line before any third container existed. Steve had approved B2 beforehand, at $24.82 a month against $12.41 (ADR: One plan, two sites), and gave his word again in the session at 11:36. The plan moved to B2 at 11:42:28, which made each site unreachable for 20 to 29 seconds (greenlane-probe-b2move.log). On B2's 3,584 MB the same sum is 49 per cent. The service's web app, APP-THEYARD-RENDER-SS, was created on the plan at 13:18 with the measured image, and answered its readiness from both APIs.
Addendum, 2026-10-08: the service's site, and how it rolls
infra/render.bicep describes the web app: the plan both sites share, read as existing; the identity both sites run as, for the registry pull and nothing else; the platform's health check on /healthz; and three settings of its own, YARD_SITES naming each site's API at its own address, the 2.5-second deadline, and a Node heap held at 192 MB, under the measured peak's ceiling, so a leak ends in a restart and not in the plan's memory. main.bicep names it as a module.
resource render 'Microsoft.Web/sites@2023-12-01' = {
name: renderName
location: location
kind: 'app,linux,container'
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${identity.id}': {}
}
}
properties: {
serverFarmId: plan.id
httpsOnly: true
clientAffinityEnabled: false
siteConfig: {
linuxFxVersion: 'DOCKER|${renderImage}'
alwaysOn: true
healthCheckPath: '/healthz'
http20Enabled: true
minTlsVersion: '1.2'
ftpsState: 'Disabled'
acrUseManagedIdentityCreds: true
acrUserManagedIdentityID: identity.properties.clientId
appSettings: [
{ name: 'WEBSITES_PORT', value: '8080' }
{ name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', value: 'false' }
// Each site by name, read at its origin: the edge's rule for each domain names the path.
{ name: 'YARD_SITES', value: 'sql=https://${toLower(sqlSite)}.azurewebsites.net,cosmos=https://${toLower(cosmosSite)}.azurewebsites.net' }
{ name: 'YARD_DEADLINE_MS', value: '2500' }
// Node's heap held under the measured peak's ceiling, so a leak ends in a restart and not in the plan's memory.
{ name: 'NODE_OPTIONS', value: '--max-old-space-size=192' }
]
}
}
}
Live from infra/render.bicep, region render-site, read from this build at 1d6b50b.
Deploy Render rolls it on the same push as Deploy and Deploy Cosmos, with the version both sites carry, and is done when the service answers its own readiness as that version. Its readiness also says, for each site, whether that site's API runs the same build. During a roll the answer is often no, and the service sends the API's own page until it is yes, so the three rolls do not have to land together. The deploy identity holds Website Contributor on this site, the role it holds on the other two, which Steve approved at 11:36.
- name: Verify
run: |
budget=1500
started=$SECONDS
body=""
until [[ "$body" == *"\"version\":\"$APP_VERSION\""* ]]; do
if (( SECONDS - started >= budget )); then
echo "the renderer never reported $APP_VERSION in ${budget}s (last answer: $body)"
exit 1
fi
sleep 5
body=$(curl -sS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$ORIGIN/readyz" || true)
done
echo "the renderer answers as $APP_VERSION after $((SECONDS - started))s: $body"
if [[ "$body" == *'"same_build":false'* ]]; then
echo "::notice::a site's API does not run $APP_VERSION yet; its pages draw in the browser until it does"
fi
Live from .github/workflows/deploy-render.yml, region render-verify, read from this build at 1d6b50b.
Addendum, 2026-10-08: in front of visitors
From 1.0.3.104 the edge sends each domain's page to the service under that site's name, and everything else, the bundle, the photographs and /api, to that site's API as before. A page is the bare path with a query, so the rule is for / alone, above each domain's catch-all; a 200 rewrite takes the query with it. The edge deploys once for both rules.
https://stevenstout.biz/* https://theyard.stevenstout.biz/:splat 301!
https://www.stevenstout.biz/* https://theyard.stevenstout.biz/:splat 301!
http://stevenstout.biz/* https://theyard.stevenstout.biz/:splat 301!
http://www.stevenstout.biz/* https://theyard.stevenstout.biz/:splat 301!
# The second site, whose default store is Azure Cosmos DB, under its own name
# (ADR: A permanent address for the second site). These two sit above the
# catch-all because the first matching rule wins, top to bottom, and the
# catch-all matches everything.
#
# Both origins are web apps on one App Service plan since 1.0.0.156, reached
# over HTTPS (ADR: One plan, two sites). Until the stopped container groups are
# deleted, the way back is `az container start` on both and these two origins
# in place of the two below:
# http://theyard-cosmos-ss-zmnetj67bn5h2.westus2.azurecontainer.io:8080
# http://theyard-ss-zmnetj67bn5h2.westus2.azurecontainer.io:8080
# Every page is the bare path with a query naming the view, the vehicle or the
# document, and the rendering service draws it (ADR: A rendering service beside the API); each
# domain's page goes to the service under that site's name, and everything else,
# the bundle, the photographs and /api, to that site's API as before. The query
# goes with a 200 rewrite. /index.html is still the API's own page, drawn in the
# browser, which is the way round while the service is down.
https://theyard-cosmos.stevenstout.biz/ https://app-theyard-render-ss-zmnetj67bn5h2.azurewebsites.net/site/cosmos 200!
https://theyard-cosmos.stevenstout.biz/* https://app-theyard-cosmos-ss-zmnetj67bn5h2.azurewebsites.net/:splat 200!
http://theyard-cosmos.stevenstout.biz/* https://theyard-cosmos.stevenstout.biz/:splat 301!
/ https://app-theyard-render-ss-zmnetj67bn5h2.azurewebsites.net/site/sql 200!
/* https://app-theyard-ss-zmnetj67bn5h2.azurewebsites.net/:splat 200!
Live from edge/_redirects, region rules, read from this build at 1d6b50b.
/index.html is not the bare path, so it still goes to the API, which answers it with its own page, drawn in the browser. That is the way round while the service is down, and the one this edge offers: it passes on whatever the service's address answers, including App Service's own error while the container restarts. A Cloudflare Worker can catch that answer and send the API's page in its place; that is the edge this site moves to at the end of October. AppServiceTemplateTests holds each rule to the address Deploy Render verifies and to the site name the service reads.
Addendum, 2026-10-08: before and after, and the window that moved
Every view was read on both domains before the edge sent pages here (1.0.3.99) and after (1.0.3.104): Lighthouse 12.8.2 on its phone and desk presets, three runs a view, and ten reads of each view's HTML (ssrlane-lh-before.log, ssrlane-lh-after.log). The full tables are on the Performance page and on React server rendering, explained.
- Bought. The inventory's speed index on a phone fell from 5.77 to 3.51 s on the Azure SQL site and from 5.60 to 3.67 s on the Azure Cosmos DB site; a vehicle's on the Azure SQL site from 5.59 to 2.95 s. Every address now arrives with its view in the HTML.
- Not bought. The landing page was drawn at build time already, so its HTML now arrives later and its largest paint moved from about 1.8 to about 2.2 s on a phone.
- Worse, and fixed in 1.0.3.105. A document read a layout shift of 1.000 on a desk. The window was drawn open in the page's flow and moved into the modal layer at the takeover. The drawn window now stands where the modal stands, over the frame on a phone and beside the rail on a desk, and
drawn.spec.ts fails if its box moves more than two pixels when React takes the page over. Read again at 1.0.3.105 on 9 October: 0.000 on both sites, score 98 where it read 74 and 73 (ssrlane-lh-after105.log).
- The stream. From the service's own address the top of the page arrives 26 to 97 ms before the drawing; through the domain the two arrive together, because the edge in use today buffers a proxied answer (
ssrlane-origin-ttfb.log). The first byte through the domain went from 196 to 241 ms to 281 to 501 ms.
Addendum, 2026-10-09 (1.0.3.106): the documents say what runs
The service went in front of visitors on 8 October and the pages that describe the running system kept describing the day before it. Hosting and both Performance overviews said one Linux B1 plan at $12.41 a month with two web apps on it, as did the README; CI/CD named two deploys where there are three; App Architecture's topology had no renderer in it; Start here did not mention render/. Each now says what this record says: B2 at $24.82 ($34.79 a month in all) with three web apps on the plan, a page's path to the service with everything else's to the API, and Deploy Render on the same push. AppServiceTemplateTests reads the plan's size from infra/appservice.bicep and fails until every page that describes the machine names it, so the next move of the plan cannot leave a page behind.
Addendum, 2026-10-09 (1.0.3.107): the drawings say what runs
The three drawings that show the machine were a day behind the pages: infrastructure.svg had one process on a B1 plan with two deploys, and CI on a pull request only; two-sites.mjs had two web apps at $12.41; dataflow.mjs had no renderer in the read path. Each is redrawn: the service beside the API's process on the plan, with the edge's page rule, the third deploy and the second image in the registry; the service as a third web app that the page rule reaches, reading each site's API and opening no store; and the page's first read running on the service, with the browser's fetch seam starting from the first load the page carries. The side-by-side drawing prices the plan at $24.82. The test that holds the pages to the template's plan size holds the two drawings' sources as well.
Addendum, 2026-10-09 (1.0.3.108): the trip, drawn
A reader of this record or of React server rendering, explained had to picture the request's path from the words. docs/images/server-page.mjs draws it in the house style. The first load runs down the left: the address, the edge's page rule, the page with its build, the loaders under their deadline, the draw with its stream, the browser painting. The takeover runs down the right: the bundle's arrival, the first load being read, every hook starting from it, the four differences answered, the page live. It opens on its own page, A page drawn on the server, in the sidebar's Diagrams section after The two sites, and the Best Practices page carries its preview.
Addendum, 2026-10-09 (1.0.3.110): the edge's copy of a page
The load reading above said what the next step was: the edge keeping an anonymous visitor's page for a few seconds. The edge in use today does not keep a proxied answer unless the origin asks it to, and the catalogue's two reads have asked since 1.0.3.79 (ADR: Cache headers, the addendum on the edge's copy of the catalogue). A drawn page now asks the same way. When a page request carries no Cookie header at all, the service answers with Netlify-CDN-Cache-Control: public, max-age=10, stale-while-revalidate=20: the edge may answer the next visitor from its copy for ten seconds, and for twenty more while it fetches a fresh page behind them. The copy is kept per query, because each view is one address with a different query, and per session cookie, Netlify-Vary: query,cookie=theyard_session, so a request that carries a session is looked up under a key of its own and never meets a page drawn for nobody. A request with any cookie, that one or another, is answered fresh with private, no-store, and nothing a session touched sits in a shared copy. The browser's rule does not change: Cache-Control still says no-cache, and a browser still asks every time.
What it costs, said plainly. An anonymous visitor can see a listing price, a sold flag or a countdown's starting second up to thirty seconds behind on a page that was kept, and the next tick of the clock moves the countdowns on. A vehicle's bid form reads the live figure from the API before any bid, as it always did. The service names the cookie in TypeScript, and CacheHeaderTests holds that name to the one the API issues, because a wrong name there would file a signed-in visitor under the anonymous key. render.test.ts holds the two shapes of the headers. Read after the roll (fablemax-proof-1.0.3.110.log): five reads of the inventory page in a row through each domain took 495, 46, 74, 74 and 65 ms on the Azure SQL site and 460, 64, 72, 96 and 99 ms on the Azure Cosmos DB site, the first a miss and the rest answered by the edge; the same page asked with a session cookie was a miss on both. Before it, every read was the service's own answer, 281 to 501 ms to the first byte through the domain (ssrlane-lh-after.log). The Performance page carries the row.
Where it sits
Outside the rings. The service is a client of the API, like a browser, and the API does not know it exists: no project in api/ references it, and the host still serves the built page as a file (ServerRenderingReadinessTests). The service's own code is the frontend's code drawn somewhere else, with the few files of its own in render/.