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.
| Mode | Runs where | What it measures |
|---|---|---|
server | On a Board Web instance, over ssh | The 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. |
client | On your own laptop | The 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.
| Endpoint | What it does | What its number means |
|---|---|---|
/up | The 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/current | 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. | One real signed-in fetch. What a page actually pays. It is also the endpoint the coming session middleware calls. |
| Connection shape | How it is produced | What it stands for |
|---|---|---|
| New connection each time | One 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 reused | One 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
| Flag | What it does |
|---|---|
--mode server|client | Required. Which side of the network to measure from. |
--env qa|prod | Required. Which environment to measure. |
--samples N | Measured requests per measurement. Default 20. |
--warmup N | Warm-up requests per measurement. Default 3. Reported on their own, never mixed into the statistics. |
--only all|api|documents | Which measurements to run. Default all. api skips the page measurements. documents skips the API measurements. |
--out PATH | Where 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 SECONDS | Give 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 PATH | The 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-run | Prints the plan and the exact curl commands. Sends no requests. |
--help | Prints the full option list in the terminal. |
Two environment variables feed the script instead of flags:
| Variable | What it does |
|---|---|
JOD_SESSION_COOKIE | The 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_NOTE | Same 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.
| Verb | Used by the script? | Why |
|---|---|---|
GET | Yes | Changes 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. |
DELETE | No | Signs the person out. A write, and it would end the session running the benchmark. |