I finally put a number on it (and the number surprised me twice)
Benchmarking Askr against FrankenPHP, PHP-FPM, and RoadRunner — the confound that nearly fooled me, and the one metric that rewrote the roadmap.
For months I'd been saying Askr was "the most efficient way to run Laravel." It's the kind of claim that feels true when you watch a request skip the entire FastCGI relay and land straight in a warm, in-process PHP worker. But feels true and is true are different countries, and the border between them is a benchmark.
So I finally sat down, spun up a container, and did the thing you're supposed to do before you make a claim: I measured it. What came out taught me two lessons — one about honesty, one about the roadmap — and both were better than the number I went in hoping for.
The rules of a fair fight
An unfair benchmark is worse than no benchmark, so before touching wrk I nailed down the controls. Same Laravel 13.18.1 app. Same PHP 8.5.8 for everyone (FrankenPHP bundles it; FPM and RoadRunner got it from the ondrej PPA; Askr's libphp is 8.5.8). Same opcache + JIT. Same 4 workers. And crucially, CPU isolation:
# server pinned to cores 0-3, the load generator to 4-7 — they never fight
taskset -c 0-3 <server> ...
taskset -c 4-7 wrk -t4 -c64 -d20s --latency http://127.0.0.1:8000/bench
Five contenders, run sequentially so nobody stole cycles from anyone else:
Askr — worker (embedded PHP, one process per core, NTS)
Askr — CoW (workers forked copy-on-write from a warm template)
Octane + FrankenPHP (embedded PHP, worker threads, ZTS — the closest peer)
Octane + RoadRunner (Go supervisor + PHP worker processes)
PHP-FPM + nginx (classic FastCGI, boot-per-request)
The plot twist: the benchmark that lied to me
My first run was a disaster dressed as a triumph. FrankenPHP came in at 75 requests per second. Seventy-five! A well-engineered server that should do thousands. And Askr? Askr reported a glorious, chest-thumping 279,000 req/s.
I did not celebrate. A 30× lead over FrankenPHP isn't a result — it's a red flag that I misconfigured something. So I validated what the servers actually returned, and there it was:
$ curl -s http://127.0.0.1:8000/bench
askr: php worker unavailable # HTTP 502
Askr's "279k req/s" was 2.4 million HTTP 502s, which wrk had happily counted as "requests." (One had ASKR_APP_BASE unset; the worker booted but couldn't find the app.) Meanwhile FrankenPHP's 75 req/s was real — and that was the more interesting bug.
The /bench route lived under Laravel's web middleware, which runs StartSession. wrk sends no cookies, so every single request created a brand-new session — and the default SESSION_DRIVER is database. Which means every request did:
INSERT INTO sessions ... -- into SQLite, which has ONE writer lock
Sixty-four concurrent connections, all fighting over SQLite's single-writer lock. It wasn't measuring the server at all — it was measuring a database write-lock, and it flattened everyone.
The fix was two lines and a lesson:
SESSION_DRIVER=array # no session persistence — same for every stack
# ...and validate every run: 200 + expected body + ZERO non-2xx, or throw it out
A req/s figure without response validation is noise. I'd nearly published noise. Write that on the wall.
The real numbers
With the confound gone and every run validated, here's /bench — a small JSON response that isolates server + framework overhead (median of 3 runs):
Stack req/s p50 p99 Askr — CoW 18,207 3.6 ms 11 ms Askr — worker 18,132 3.1 ms 14 ms Octane + FrankenPHP 8,563 7.3 ms 11 ms PHP-FPM + nginx 4,380 14.4 ms 17 ms Octane + RoadRunner 3,861 12.9 ms ~100 ms
So on raw server overhead, Askr lands at roughly 2.1× FrankenPHP (the closest embedded-PHP peer), ~4× PHP-FPM, and ~4.7× RoadRunner.
But I promised honesty, so here's the counterpoint. Swap /bench for /bench-db — a real SELECT count(*) — and the field compresses:
Stack req/s Askr — CoW 13,424 Askr — worker 8,973 Octane + FrankenPHP 7,249 PHP-FPM + nginx 3,868 Octane + RoadRunner 3,307
Askr still leads, but the gap narrows — because now the query is doing the work, and the server matters less. That's the honest shape of it: Askr's advantage is biggest where framework and boot overhead dominate, and shrinks as your app does more real work per request. Anyone who tells you their server is 4× faster for your app is selling you a microbenchmark.
The metric that rewrote the roadmap
Here's where the benchmark earned its keep. My next planned feature was a per-core io_uring I/O core — weeks of Rust, a whole runtime rewrite, "the biggest efficiency step." Before building it, I asked the data one question: is I/O even the bottleneck?
Askr exposes its own timing, so during a 20-second load I just read the meter:
askr_php_seconds_total 1261.2
askr_request_seconds_total 1267.5
Do the division. PHP execution is 99.5% of request time. Everything else — accept, read, write, routing, the entire Rust layer — is 0.5%.
io_uring optimizes exactly that 0.5%. A multi-week rewrite to shave a sliver off half a percent, while the PHP engine sits at 99.5% completely untouched. The benchmark didn't just validate a claim — it talked me out of the wrong project. io_uring is now deprioritized, in writing, with a reason. The levers that actually matter — boot once, opcache + JIT, no FastCGI hop — are the ones Askr already pulls.
That, to me, is the whole point of measuring. Not the bragging rights. The course correction.
Why Askr comes out ahead
The architecture explains the gap without any magic:
No per-request boot. Like Octane and FrankenPHP, the app boots once. Unlike FPM, there's no framework bootstrap on every request.
No FastCGI hop. PHP runs in-process — no socket serialization between a web server and a PHP pool.
Processes, not threads. This is the real difference from FrankenPHP: Askr runs non-ZTS PHP, one process per core, so there's no thread-safety locking on the hot path. The OS scheduler does the work. That's most of the ~2×.
Copy-on-write shares a warm template across workers — competitive throughput at slightly lower memory.
The caveats, because I'd want them
Shared dev VM. Run-to-run variance was real (one Askr-CoW sample came in ~20% low; the tables use medians). Read these as ratios and shapes, not lab-grade absolutes.
RSS is summed resident memory, which overcounts shared pages — it flatters the one-process thread model (FrankenPHP) and penalizes the many-process ones (Askr, FPM, RoadRunner). PSS would be fairer; I didn't measure it.
RoadRunner ran on Octane's defaults. Its ~100 ms p99 says there's tuning I didn't chase. Don't read its number as RoadRunner's best.
Small payloads, arm64, 4 workers, session=array. This is server + framework overhead, not database throughput or large-response streaming.
The whole harness — including the confound and the validation — is in docs/BENCHMARKS.md. Run it yourself; that's the only benchmark worth trusting.
The takeaway
Two surprises, then. The first: the claim held — Askr really is about twice the closest in-process peer and four times FastCGI on server overhead, and the numbers are honest because I threw out the ones that weren't.
The second, and the better one: measuring told me not to build the thing I was about to build. The bottleneck was never I/O. It was always PHP — which is exactly the thing Askr already makes as cheap as it can be.
Measure before you optimize. Validate before you brag. And when the data tells you your next feature is a waste of time, thank it and go build something that isn't.
Now — HTTP/3, anyone? 🌳