Skip to main content

Internal API benchmark — overview

The internal API benchmark is a shell script. It answers one question for Board Web: 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 two choices pay very different network costs. The script measures both, so the decision rests on numbers, not a guess.

Board Web's server render calls the private host, read at runtime from API_INTERNAL_JODAPP_URL (http://api.internal.jodapp.dev in QA, http://api.internal.jodapp.com in production). The browser calls the public host, read from VITE_API_JODAPP_URL. Server mode below measures both paths from the instance. Internal API benchmark — 2026-09 analysis, Finding 1, has the numbers.

The recorded 2026-09 runs predate the server's move to the private host. Their API numbers cover both hosts, measured directly from the instance. Their page numbers cover the public host only. A server-mode run with API_INTERNAL_JODAPP_URL set is the next measurement to record.

For the numbers from the first production runs, read Internal API benchmark — 2026-09 analysis. For how to run the script, read Internal API benchmark — runbook.

Where the script lives​

The script is jod-latency-benchmark.sh, in jodapp-web/scripts/internal-benchmark/jod-latency-benchmark.sh.

It sends HTTP GET requests with curl and measures how long each one takes. It prints progress to the terminal and writes a Markdown report.

Three things it never does:

  • It never writes to any server. It only sends GET requests.
  • It never calls the AWS CLI, and needs no AWS credentials.
  • It never prints or stores your session cookie.

What it measures​

The script runs in one of two modes.

ModeRuns whereWhat it measures
serverOn a Board Web instance, over sshThe web server calling the Rails API over both hosts: the private host the server render uses, and the public host the browser uses. Measuring both in one run, from one machine, is the only honest comparison of the two paths, because nothing but the host differs. Also the web server rendering a page, at http://localhost with a Host header. 276 requests.
clientOn your own laptopThe browser calling the Rails API over the public internet, through Cloudflare. Also a public page arriving at your laptop. This is the clientLoader() path. 253 requests.

Inside each mode, the script calls the Rails API at two endpoints. It sends every request twice over: once on a new connection, and once on a connection it reuses.

EndpointWhat it doesWhat its number means
/upThe Rails health route. Touches no database.The network floor. The smallest cost the path can have. No page ever calls it, so this is not the cost of a data fetch.
The session endpoint, default GET /identities/user_sessions/currentReads the session row named by the signed cookie, then asks two services whether the person may enter the talent area and the employer area.One real signed-in fetch. What a page actually pays. It is also the endpoint the coming session middleware calls.
Connection shapeHow it is producedWhat it stands for
New connection each timeOne curl process per request. A new process cannot reuse a connection.The first call after a cold start. It pays DNS, TCP and TLS.
Connection reusedOne curl process, many requests. curl keeps the connection open.Real life. Node's built-in fetch keeps connections alive by default, and so does a browser.

The gap between the two endpoints is the Rails work: reading the session row, loading the user, and building the JSON. That gap is the same on both paths. What changes between the private and the public path is the network cost added on top of it.

The script also measures four pages, to show render cost on top of the API cost:

  • /up
  • /privacy
  • /jobs
  • /employers/dashboard (signed in)

Command-line options​

FlagWhat it does
--mode server|clientRequired. Which side of the network to measure from.
--env qa|prodRequired. Which environment to measure.
--samples NMeasured requests per measurement. Default 20.
--warmup NWarm-up requests per measurement. Default 3. Reported on their own, never mixed into the statistics.
--only all|api|documentsWhich measurements to run. Default all. api skips the page measurements. documents skips the API measurements.
--out PATHWhere to write the report. - means stdout. Default: results/<env>-<mode>-<UTC timestamp>.md.
--network "TEXT"Required in client mode. Where the client-side run happened, for example "home fibre, Singapore". The report prints it, because a different network gives different numbers.
--max-time SECONDSGive up on one request after this long. Default 20.
--user-agent "TEXT"The User-Agent header for page requests. It defaults to a browser string. Do not set a bot-like value: the server renders a whole page for a bot instead of streaming the shell, which inflates the time to first byte. API requests always send jod-latency-benchmark/<version>.
--session-endpoint PATHThe signed-in Rails API endpoint that stands for real work. Default /identities/user_sessions/current. Must answer a GET. A heavier one to compare against: /identities/users/current.
--dry-runPrints the plan and the exact curl commands. Sends no requests.
--helpPrints the full option list in the terminal.

Two environment variables feed the script instead of flags:

VariableWhat it does
JOD_SESSION_COOKIEThe Cookie header value for a signed-in page. Without it, the script skips the signed-in measurements and says so in the report.
JOD_NETWORK_NOTESame as --network.

The script writes the cookie to a private curl config file. The file mode is 600, so only the current user can read it. The cookie never appears in ps output, and never appears in the report.

Why the GET, and not the DELETE​

The path /identities/user_sessions/current answers two HTTP verbs. The script only ever sends GET. Login is POST /identities/user_sessions, a different path.

VerbUsed by the script?Why
GETYesChanges nothing. It reads the session row named by the signed cookie, then asks two services whether the person may enter the talent area and the employer area.
DELETENoSigns the person out. A write, and it would end the session running the benchmark.