Devora.js 0.3.5: less work per request, and what it did and did not change

Five profiled changes to devora start, one clearer message for a missing session store, and the measured effect: where Devora.js is ahead, level, and still behind.

0.3.5 is a performance and housekeeping release. @devorajs/core and @devorajs/cli are 0.3.5 and create-devora is 0.3.3 (new projects pin ^0.3.5). The three adapters are unchanged. The complete list is the 0.3.5 section of the CHANGELOG; this post is the short version, with the numbers and their limits.

What an existing app has to do

Nothing in a typical app. The CHANGELOG lists four behavior changes; each is small:

  • A missing session store, or (in production) a missing DEVORA_SESSION_SECRET, stops devora start with one clear line and exit status 1 instead of a stack trace. It refused to start before and still does.
  • ctx.url and ctx.headers are built the first time a handler reads them. Reading them is unchanged. They are now getters, so a handler that assigns to them throws in strict mode; nothing in the framework does.
  • A rebuilt island or client-route manifest is picked up within a second.
  • The header's brand link has the accessible name devora.js home (it was Home, which did not contain its visible text). A test that selects it by aria-label="Home" needs the new name.

The five performance changes

Each came from a CPU profile of a running devora start (node --cpu-prof), not from a guess, and each has a test. In short, work that never changes was being redone on every request:

  • ctx.url and ctx.headers were built even when no handler read them.
  • Build keys, module lookups and the paths of pre-rendered pages were recomputed per request, and the request path went through the URL parser even when it was plain. A plain path is now read straight from the request line; anything unusual still goes through the parser, and a test compares both on thousands of generated paths.
  • The store key for a session ID (an HMAC) is remembered per secret, cookie name and ID, in a memory bounded to 5,000 entries. The value is the same, so existing sessions keep working and an isolated app still cannot read a shared app's sessions.
  • The island and client-route manifests were read from disk on every server-rendered request; they are now read at most once a second. On a form POST this was about a third of the profile (file system calls were 33.7% of samples in 0.3.4 and 0.1% after).
  • The absolute request URL behind ctx.url is parsed when first read.

No security default was changed. The tests that cover the paths these changes touch: a remembered session key never outlives a session (revoked, deleted, expired, after the memory is flooded, in another app's scope); the remembered page paths stay bounded and keep refusing traversal; and a page form POST gets a 403 without, with a missing or with a wrong CSRF token and a 303 with a valid one, with the 0.3.4 values for the security headers and cookie flags.

What it measured

The benchmark harness (the same pages and the same JSON API in several frameworks, one machine, the load tool on that machine, 5 runs of 10 s per scenario) was run on the published 0.3.4 packages and again on the published 0.3.5 packages. Medians, with the lowest and highest run in brackets:

Scenario0.3.4, req/s0.3.5, req/s
Pre-rendered page71,479 (68,049–73,376)101,891 (97,155–105,289)
Server-rendered page9,475 (9,159–9,484)11,736 (11,611–11,921)
Form POST31,988 (28,631–32,094)79,136 (78,271–81,871)
JSON list (API route)30,524 (26,001–37,802)49,529 (49,258–51,050)
Authenticated GET (API route)43,169 (42,768–43,347)77,228 (76,431–77,635)

The 0.3.4 API runs were noisy (the JSON list ranged from 26,001 to 37,802), so the gap there is overstated. A second measurement is steadier: an interleaved A/B of the 0.3.4 packages against the same code before it was published, alternating builds on the same machine over 3 rounds, gave about +15% on the JSON list, +32% on the authenticated GET, +21% on a valid POST, +41% on the pre-rendered page and +23% on the server-rendered page (benchmarks/results/ab-published-vs-final.txt). Take the A/B figures as the better estimate of the change, and both as one machine's numbers.

Where it is still behind

The performance page and the repository's reports call a difference smaller than a framework's own run-to-run spread a tie. By that rule, on 0.3.5:

  • Ahead of the other four frameworks on the pre-rendered page, the server-rendered page and the form POST (Next.js, Astro, SvelteKit, React Router).
  • Behind Fastify, Hono and NestJS on its Fastify platform on every JSON API scenario; on the authenticated GET also behind Express and NestJS on its Express platform. On the JSON list, Fastify reached 66,678 req/s against 49,529; on the authenticated GET, 134,383 against 77,228.
  • Level on the scenario with one awaited 2 ms step, where five results overlap: the timer, not the framework, sets the rate.

Part of that gap is work the bare frameworks do not do: Devora.js sends CSP, HSTS and X-Frame-Options on every response and runs a real session layer for the authenticated scenario, and these numbers keep both. Memory is higher too: 285 MB after the API scenarios against 167 to 258 MB for Express, Fastify, Hono and both NestJS platforms (and lower than Next.js, SvelteKit, React Router and Astro). A faster server also keeps more heap, so that is not proof of a leak; a 10-minute mixed-load run on the code that became 0.3.5 held steady at about 314 MB.

Upgrade

npm install @devorajs/core@^0.3.5 @devorajs/cli@^0.3.5

Upgrade core and cli together, in the root and every app's package.json. The steps and the earlier tables are on Upgrading to 0.3.5; the numbers are on Performance.