Skip to main content

Internal API benchmark — 2026-09 analysis

This page reads the 2026-09 benchmark runs and states what they mean for Board Web.

The benchmark answers one question: should a page fetch its data in a React Router loader(), or in a clientLoader()?

  • A loader() runs on the Board Web server. The server calls the Rails API over the private path inside the VPC, then sends finished HTML to the browser. The user waits once.
  • A clientLoader() runs in the browser. The page shell arrives first. Then the browser calls the Rails API itself, over the public internet, through Cloudflare. The user waits twice, and the second wait leaves the page half empty.

The raw reports are 2026-09 benchmark — prod, server side, and three client-side runs from the same afternoon: 09:56, 14:32, and 14:46. For what the script measures, read Internal API benchmark — overview.

How this was measured​

Several runs happened on the afternoon of 2026-09-01, while the script's User-Agent handling was fixed and re-tested.

ItemServer-side run (archived)
Runs onThe prod Board Web instance
Machineip-10-0-143-187
MeasuresBoth hosts, private name and public host, from one machine in one run
Started (UTC)2026-09-01T14:56:34Z
Total requests276
Measured samples per measurement20
Warm-up requests, reported separately3

Three client-side runs happened the same afternoon, all from the same laptop on home fibre in Jakarta, Indonesia (IndiHome), machine Nerdherd-M2-Max.local. All three are archived, because Finding 5 is entirely about the difference between them:

RunStarted (UTC)Report
09:562026-09-01T09:56:19Z2026-09 benchmark — prod, client side, 09:56
14:322026-09-01T14:32:22Z2026-09 benchmark — prod, client side, 14:32
14:462026-09-01T14:46:23Z2026-09 benchmark — prod, client side, 14:46

Each client run measured 253 requests: 20 measured samples and 3 warm-up requests per measurement.

The server-side run is authoritative for Finding 1 and Finding 6. It measures both hosts from the same machine, in the same run, so nothing except the host differs between the two numbers being compared.

Some numbers on this page have no surviving report​

Only the final server-side run, above, was kept. The runbook that produced these reports told the reader to write every server-side run to one fixed filename, --out /tmp/prod-server.md. Each new run overwrote the report before it, so earlier server-side runs cannot be checked against a file.

Findings 1, 3, and 7 quote numbers from those earlier runs. The numbers are correct — they were read from the reports while the reports still existed — but a reader cannot check them the way every other number on this page can be checked. Each one is marked where it appears, with a link back to this note.

The runbook sets no fixed filename, so every run keeps its own timestamped report.

Every number below is milliseconds.

TermWhat it means
TTFB (time_starttransfer)The wait until the first byte of the response arrives.
Whole response (time_total)The wait until the last byte arrives.
Connection reusedOne curl process sent many requests over one open connection. This is the realistic shape: Node's built-in fetch keeps connections alive by default, and so does a browser.

Read every finding below from the connection-reused numbers, unless a section says otherwise.

These runs were made with the server on the public host, before the server's move to the private one: the API numbers below cover both hosts, measured directly from the instance; every page number covers the public host only; a server-mode run with API_INTERNAL_JODAPP_URL set is the next measurement to record.

Finding 1 — the private path costs 12.67 ms less per API call than the public path​

Board Web's server render and the browser both call the Rails API through apiClient, built in app/api/ky-client.js, but over different hosts. The server reads its base URL at runtime from API_INTERNAL_JODAPP_URL (http://api.internal.jodapp.dev in QA, http://api.internal.jodapp.com in production), at app/api/ky-client.js:346, and deploy/deploy.yml:48 passes it to the container. The variable has no VITE_ prefix, so it never reaches the browser bundle: the client reads it only when import.meta.env.SSR is true, and falls back to the public host when it is unset. The browser reads VITE_API_JODAPP_URL, the public host api.jodapp.com, and its calls cross the public internet through Cloudflare. So a loader()'s call travels the private path, and a clientLoader()'s call travels the public one. The server-side run measured both hosts from the same instance, so the difference below is the cost the public internet adds to one call.

One real signed-in API call from the web server, connection already open:

One API call from the web serverMedian
The public host, api.jodapp.com24.02 ms
The private host, api.internal.jodapp.com11.35 ms
Difference per API call12.67 ms

The private call is about 2.1 times faster. The network floor alone — /up, which touches no database — is 13.92 ms on the public host and 3.95 ms on the private one, a 9.97 ms difference and about 3.5 times faster.

The two ranges do not touch: the public call ran 21.17 to 29.14 ms across its 20 samples, the private call 10.48 to 12.46 ms. Nothing links them.

The private path is consistently this fast. Its /up floor measured 4.52 ms, 3.98 ms, and 3.95 ms across three separate runs this same day, and its real signed-in fetch measured 12.62 ms, 11.23 ms, and 11.35 ms. That consistency is what makes this finding trustworthy, unlike the public-path numbers in Finding 5. Only the last value in each pair, 3.95 ms and 11.35 ms, has a surviving report to check. See Some numbers on this page have no surviving report.

Finding 2 — the private path does not change the page render time​

The 12.67 ms difference in Finding 1 is real, but it is small next to the problem in Finding 3.

/privacy rendered in 976.62 ms on the authoritative run, with the server calling the public host. If one render makes one API call, the private path accounts for 12.67 ms of that, about 1.3%. Even at three API calls in one render, the private path accounts for about 38 ms, under 4%.

The private path is still the right path for the server. It roughly halves the cost of one API call, keeps Cloudflare out of the server render, and keeps internal traffic off the public internet. But it is not what decides the page render cost in Finding 3, and nothing on this page should be read as implying that it is.

Finding 3 — the pages are slow, getting slower, and it is not the web server​

Four server-side runs happened on 2026-09-01, with the server calling the public host. Median whole response (time_total), milliseconds:

Page09:5614:3014:5414:56
/privacy499.51525.80808.60976.62
/jobs531.52556.81830.66829.29
/employers/dashboard52.4258.76498.00343.16
/up, the control10.1711.6110.4510.11

Only the 14:56 column has a surviving report. See Some numbers on this page have no surviving report.

/up runs no loader and no session code. It stayed at about 10 ms across all four runs. So the web server itself is healthy, and the rest of this table is not measurement drift. Every page that runs a loader() roughly doubled across the afternoon.

The cause is not known. It is not the endpoint this benchmark measures — the private-path real fetch stayed at 11.23 to 12.62 ms throughout the same afternoon, per Finding 1. Whatever is slowing these pages down, it is not that one API call. It is whatever else those loaders call.

What would settle it: count the API calls one render actually makes, and time each one.

The 14:54 /employers/dashboard figure, 498.00 ms, was measured with an expired session cookie. See Finding 7 for what that costs on its own.

Finding 4 — the app never streams, so TTFB and whole response measure the same thing​

jodapp-web contains no Suspense, no <Await>, and no defer( anywhere in app/ — the count is zero. With nothing deferred, React Router has to resolve every loader before it can send any HTML. Nothing can arrive early.

So on every page measurement in this report, TTFB sits within about 1 ms of the whole response. That is correct behaviour for this application as it is built, not a fault in the measurement. Treat the "whole response" column as the number that matters; TTFB adds no separate information on these pages.

Finding 5 — the browser path is too variable to put a single number on​

Three client runs happened, all from the same laptop on home fibre in Jakarta, on the same afternoon:

Run/up floorReal fetchDerived difference
09:56111.56 ms113.25 ms+1.69 ms
14:32381.95 ms41.70 ms-340.25 ms
14:4631.78 ms239.33 ms+207.55 ms

The derived difference is meant to estimate the Rails work: the network floor subtracted from the real fetch. The private path measures that steadily at about 7.40 ms, per Finding 6. Measured against that, all three public-path figures above are noise — including the two, +1.69 ms and +207.55 ms, that happen to look plausible on their own.

Two things follow from this.

  • There is no single multiplier between loader() and clientLoader(). A single run can make one number look like the ratio. Three runs from the same laptop, on the same afternoon, produced three different ratios instead. Use the range in the table above, not a multiplier.
  • The variability is itself the finding. A loader() over the private path costs 11.35 ms, ranging 10.48 to 12.46 ms across 20 samples. A clientLoader() from this one connection cost between 42 ms and 239 ms depending on which minute it ran. Predictability matters as much as the median.

Finding 6 — the Rails work is 7.40 ms​

11.35 ms minus 3.95 ms, both from the private path in the authoritative run: the real signed-in fetch, minus the /up network floor. Both numbers come from the same run, over the same host.

The two ranges are disjoint — 10.48 to 12.46 ms against 3.62 to 4.56 ms — so this difference is real, not noise.

This number cannot be derived from the public path. Finding 5 shows why: the public floor and the public real fetch both move by hundreds of milliseconds between runs, so subtracting one from the other cannot isolate an 8 ms signal.

Finding 7 — an expired session makes a page far slower​

/employers/dashboard, server side:

CookieMedian whole responseBytes
Valid, 09:56 run52.42 ms6831
Expired, 14:21 run709.96 ms5192

Neither row has a surviving report to check — the 09:56 figure is the same server run flagged in Finding 3, and the 14:21 run was itself overwritten before this page was written. See Some numbers on this page have no surviving report.

With an expired cookie, the page rendered smaller — a signed-out page, not the dashboard — and took about 13 times longer.

An expired cookie costs one call. Rails answers 401, and the API client hands that error back to its caller once. Nothing renews the cookie and nothing retries the request. See JodApp Web D1 — Nothing refreshes; a 401 means the session is gone.

Finding 8 — ignore the new-connection numbers on the public host​

In the authoritative run, opening a new connection to the public host measured:

Block, TTFB, new connection each timeminmedianmax
Public host, /up60.30621.631516.95
Private name, /up5.936.438.19

The public host shows a spread of about 25 times between its fastest and slowest sample. The likely cause is 23 TLS handshakes to Cloudflare, opened in quick succession from one address.

Treat the connection-reused figures elsewhere on this page as the realistic ones. Node's fetch keeps connections alive by default, so a real server rarely pays the cost of opening a new one.

Finding 9 — the session middleware calls a different endpoint than this benchmark measured​

The session middleware on each root route asks Rails who is signed in. It calls GET /identities/user_sessions/current, at most once per request. This benchmark measured a different path, GET /identities/sessions/current. So read the numbers here as the cost of one session read, not as the cost of that exact path. Reading the session states the rules for the call.

Limits of these results​

State these limits honestly when quoting the numbers above.

LimitWhy it matters
The public-path numbers move by hundreds of milliseconds between runs from the same laptop, on the same afternoonTreat any single public-path measurement as one sample, not a stable estimate. See Finding 5.
Measurements within one run happen in separate sequential blocks, not randomised or interleavedConditions can drift between blocks. This is why the three client runs in Finding 5 derived three different values for the same quantity, and why Finding 8's new-connection numbers on the public host cannot be trusted.
No QA server-side run existsOnly production runs exist.
Every page timing was recorded with the server on the public hostNo page timing over the private host exists. A server-mode run with API_INTERNAL_JODAPP_URL set is the next measurement to record.
The page measurements in Finding 3 do not all share the same session-cookie state, and Finding 7 shows cookie state alone can be worth 13 times the render time/privacy, /jobs, and /employers/dashboard are not measuring identical conditions, beyond the route itself.
The page render cost moved a lot within one afternoonAny single page number, including the ones in Finding 3, is a snapshot, not a stable baseline.
Some of the numbers above have no surviving reportSee Some numbers on this page have no surviving report.