Skip to main content
These numbers come from one laptop, taken while other work was running on it. They show what changed between two builds of the same code. They are not a throughput claim for your deployment, and nothing here compares TheAuth with anything else. The general benchmark harness is described on Benchmarks; this page covers the micro benchmarks and the profile driven fixes.

Where the numbers come from

Both sides are bundled with esbuild from source, not from dist, so a stale build cannot skew a run. benchmarks/micro-build.mjs builds either tree.

How medians and noise are stated

Each micro benchmark is timed in batches. One run is the median over 9 batches. A result on this page is the median of several runs, with the lowest and highest run in brackets, and the run order alternated between before and after. Wall clock was too noisy on a busy laptop (the same case moved by 30 to 50 percent between rounds, and some crypto cases swung by more than the change being measured). The micro benchmark tables below therefore use --clock cpu, which counts process CPU time (user plus system, all threads). With it the spread between runs was under 10 percent for most cases, and the brackets show it. The database timings are wall clock per operation; their brackets are wider and are shown too.

What the profiles showed

node --cpu-prof on the existing authorize scenario put roughly half of the self time in drizzle query building and the SQLite driver, so the first lever was the number and cost of queries, not the matcher. EXPLAIN QUERY PLAN then showed full table scans on lookups that every authorize and token check makes. In the micro benchmarks, the top self costs were TextEncoder.encode (inside the key sort of the approval hash), a fresh importKey per call for HMAC secrets, and a fresh JWKS import per device bundle verify.

Results

Database paths

SQLite file, 50,000 agents with 4 permissions each, one delegation link each (a third revoked), 7 runs per setting, 3 rounds. Milliseconds per operation, median with the lowest and highest round. The wall clock spread inside a round reached 20 to 60 percent for some of these on the loaded machine, but the before and after ranges do not overlap. The cause is the new migration 0014-hot-path-indexes. Without indexes, SQLite and Postgres scan theauth_agents (by token_hash), theauth_permissions (by agent_id), theauth_delegation_chains and theauth_rate_limits on every call, so the cost grows with table size. MySQL already indexes foreign key columns, so it only gets the composite and token_hash indexes. Databases with the MCP module enabled already had the two delegation chain indexes from migration 0007; on a 5,000 row table with MCP on, a direct authorize went from 0.52 to 0.076 ms and a three hop chain from 0.86 to 0.36 ms (single round, noisy). Reproduce with:
The last command removes the new indexes after boot, which shows what they are worth on the current code.

The standard harness, small tables

benchmarks/run.mjs (profile smoke, better-sqlite3 in memory, 3,000 iterations, p50, median of 5 runs). Tables here are small, so the index effect is mostly absent and the gains come from fewer queries. The delegation gain comes from reading each agent’s inbound links once (the liveness walk used to run one query for the live links and a second one for every link ever received) and from reading an agent and its permissions together.

CPU hot paths, no database

Nanoseconds per operation (CPU time), median of 3 rounds of 9 batches, lowest and highest round in brackets. Two honest notes. The DPoP proof carries a new public key every time, so there is no key to cache and nothing changed. The offline bundle decision with two permissions got a few nanoseconds slower, because the new colon matcher pays a little more than split on very short inputs; it wins as soon as a list has more than a handful of entries. The MCP validation figures use the HMAC secret path with a fake revocation store holding 5,000 denylist entries. The revocation lookup itself is a set or indexed read and was not the cost in any mode; the key import and JWT parsing were.

What changed

All of these keep decisions identical. Each fast path has a test that compares it with the code it replaced over a seeded random corpus (tests/perf-equivalence.test.ts, tests/delegation-liveness-queries.test.ts, tests/admin-api-list-queries.test.ts, tests/migrate-hot-path-0014.test.ts). No check was removed or reordered.
  • Indexes (migration 0014-hot-path-indexes): theauth_agents(token_hash), theauth_agents(owner_id, status), theauth_permissions(agent_id), theauth_delegation_chains(to_agent_id, status) and (from_agent_id, status), theauth_rate_limits(agent_id, resource, window_start), theauth_approval_requests(status, created_at). The query plans before and after are asserted in the migration test.
  • HMAC keys: a string secret is imported once and the non extractable CryptoKey is reused (at most 32 secrets, oldest dropped). Used by session cookies, the stateless session ring and MCP HS256 verification.
  • Device bundles: the JWKS resolver and the signing key are reused while the same JWKS or JWK object keeps the same content. A fingerprint of the content is compared on every call, so a key rotated in place is picked up at once.
  • Resource matching: the default colon matcher walks both strings with indexOf instead of splitting them, and the path matcher skips its table when the pattern has no **.
  • Approval hash: strings that need no escaping skip the character loop, and keys are ordered by byte without encoding two copies per comparison when both are ASCII.
  • Delegation liveness: one read per agent for inbound links, filtered in memory for the live ones, instead of two reads.
  • Admin API: the pending approvals list reads quorum approvers for the whole page in one query instead of one per request, and the delegation list without a status filter reads only limit rows instead of the 500 row ceiling.
One attempt was dropped. Restricting the delegation impact view’s denylist read to the links shown cost more than it saved when the denylist is small, so it is unchanged.

Not changed on purpose

  • DPoP proof keys are not cached: they come from the request.
  • Nothing is cached across authorize calls. A cached delegation or agent row would delay a revocation, and revocations must take effect on the next call.
  • The rate limit counter still does a read then a write per allowed call. Making it atomic is a separate change.

Run the micro benchmarks yourself

The output file lists, for every case, the median and the lowest and highest run, so you can judge the noise on your own machine before trusting a difference.
Last modified on October 19, 2026