23 KiB
Navigation benchmark: footstrap vs bootstrap vs proton2025
luci-theme-footstrap ships a client-side router, so clicking a menu item swaps the view in
place instead of reloading the whole page. This measures what that is worth, on real hardware,
against stock luci-theme-bootstrap and against a third-party theme.
Script: bench/nav-benchmark.py. Runs against any OpenWrt 25.12+ router.
What it measures
For every page, wall-clock milliseconds from "I want to go there" to "the view is fully rendered":
- footstrap: a real click on the menu link, which the theme's router handles in place;
- the others: a full navigation to the URL, because that is what a click does in them.
Both themes render into the same #view element, which the LuCI dispatcher emits regardless of
theme, so the finish line is identical and the comparison is fair. "Rendered" means #view has
children, nothing is spinning, no "Loading view…" text, and none of the content belongs to the
page you just left.
Pages come from /admin/menu at runtime: everything the menu can turn into a link whose
resolved target is a stock LuCI page. Third-party app pages are excluded by construction, so an
app that hangs itself under admin/system cannot skew the numbers. That gives 38 pages,
including the tab leaves (Realtime, Logs, Administration, Firewall) and the alias/firstchild
entries like Firewall and Realtime Graphs, which are the most-clicked items in the menu.
Per theme: activate it, clear the LuCI caches, log in with a fresh browser context, walk every
page once unmeasured to warm the HTTP and module caches, then walk it --runs more times and
take the median per page. Network requests per transition are counted too.
CPU: two tables, because "load" is two questions
Time tells you how long you waited. It does not tell you what that cost, and the two can disagree: a theme can be quick because it pushed the work onto the router, or easy on the router while burning the client's battery. So both ends are measured and reported apart.
Router CPU is read from /proc on the router itself, at the edges of each theme's measured
passes. The attributable figure is utime+stime+cutime+cstime of uhttpd, rpcd and ubusd.
The cutime/cstime halves matter: uhttpd forks a CGI per request, and a reaped child's CPU
lands in its parent, so the ucode that renders the page shell is counted. There is also a
whole-box figure from /proc/stat, printed next to an idle baseline taken before and after,
since the router also routes traffic.
An open LuCI view polls the router once a second, which is CPU nobody navigated for. That rate
is measured separately, parked on one page, and subtracted before dividing by the navigation
count. Two things to keep in mind when reading the table: the percentage rows are rates over a
window whose length the theme changes, so a faster theme can show a higher percentage on less
CPU and only CPU-seconds compare; and /proc/stat inside a Docker container is the host's, so
the whole-box figure there would include the benchmark's own browser. That is why the published
numbers come from real hardware.
Client CPU comes from CDP Performance.getMetrics deltas per navigation: TaskDuration for
main-thread task time, with a script / style-recalc / layout / v8-compile breakdown.
Why footstrap comes out ahead
Nothing exotic. A full page load throws away a working runtime and rebuilds it: the shell, the
menu, luci.js and cbi.js, the translation catalogue, the theme's CSS and JS. Then it renders
the page. The client router keeps all of that and renders only the page.
You can see it in the request counts. bootstrap fires 15 to 47 requests per navigation and proton2025 27 to 72, against 0 to 7 for footstrap, which fetches only the RPC the view needs. Zero on some pages, because the view is already in memory.
That is also why the win is uneven. It is biggest (8x and up) on light pages, where the page itself has almost nothing to do and a reload spends its whole time restarting the runtime. It is smallest (around 1.1x) on Startup, Software and Overview, where the time goes into rendering content rather than navigating. The router cannot speed up a package list. That is a ceiling, not a defect.
One more thing the numbers show: a third-party theme can be slower than the stock theme it repaints. proton2025 loses to bootstrap on total time, with roughly twice the requests and four times the CSS. Pretty is not fast, which is the argument for measuring.
The remaining LAN time is not the theme's, and it is not the router's either
On a LAN a warm navigation still costs 90–140 ms while the router answers most of its own RPCs in 1–4 ms. That gap is worth writing down, because the obvious suspects are all innocent and the real cause sits two layers below this theme.
The theme is 4.9% of it. CPU-profiling the main thread across 14 warm navigations on real
hardware (Profiler.setSamplingInterval 100µs, self time by script): every fs-*.js file together
is 6.8 ms per navigation out of ~139 ms — fs-chrome 4.6, fs-fit's roomFor 1.2, fs-router
0.4, fs-select 0.2 — against 95.9 ms in which the main thread is idle and 21.5 ms of engine work
(style, layout, paint). Another 3.7 ms/nav is scrollTo, which is ours. So zeroing the theme
entirely would buy under 8%, and the chrome re-render everyone suspects (renderChrome, which does
rebuild the menu on every navigation) measures 0.55 ms.
The router is not slow either. Per-request timings from the CDP network service (immune to
renderer busyness) show server time of 1.0–4.0 ms for almost everything, including a 22 KB
network.interface.dump batch at 1.4 ms — but 42–44 ms, every single time, for session.access
(205 bytes) and for a lone luci.getUnixtime (211 bytes). Run the same call with the main thread
idle and it takes 2.8 ms; ubus call session access on the router itself takes 0 ms.
It is Nagle plus delayed ACK, and uhttpd is where it lands. uhttpd writes a response's headers
and body as separate write()s and never sets TCP_NODELAY, so on a reused keep-alive connection
a small body waits for the ACK of the previous response — which the client's kernel delays by ~40 ms.
Reproduced with curl, no browser involved, four sequential POSTs on one connection:
req1 uci.changes 52 B 7.2 ms
req2 session.access 53 B 44.0 ms <-- small body, previous response unacked
req3 getNetworkDevices 8913 B 9.6 ms <-- a full MSS goes out immediately
req4 session.access 53 B 42.8 ms
fresh connection each time: 1.4 / 1.5 / 1.4 ms
That also explains the shape: TTFB is 0.37 ms (the headers are prompt), the body is late; the first request of a connection never stalls; large responses never stall.
A workaround exists, it is NOT theme-specific, and over HTTPS it is a trap.
uci set uhttpd.main.http_keepalive=0 removes the stall (uhttpd then answers Connection: close on
both protocols). Measured on the production router, aarch64 25.12.2:
| keep-alive 20 | keep-alive 0 | ||
|---|---|---|---|
| footstrap, warm navigation, 6 pages, LAN | 754 ms | 453 ms | 1.66× |
| footstrap, cold full load, LAN | 292 ms | 155 ms | 1.88× |
| footstrap, warm navigation, 120 ms RTT | 1825 ms | 1834 ms | unchanged |
| footstrap, cold full load, 120 ms RTT | 1551 ms | 1551 ms | unchanged |
| stock bootstrap, full-load nav, 6 pages, LAN | 2126 ms | 1705 ms | 1.25× |
| stock bootstrap, full-load nav, 120 ms RTT | 4782 ms | 4814 ms | unchanged |
| HTTPS, full-load nav, 120 ms RTT | 9819 ms | 29 424 ms | 3× WORSE |
Stock bootstrap gains too, which is the point: this is uhttpd's, not the theme's. Plain HTTP loses nothing even at 120 ms RTT, because the handshakes overlap across the six connections a browser opens while the Nagle stall is serial. But keep-alive is load-bearing for TLS: without it every request pays a TCP handshake plus a TLS handshake, and a page is ~49 requests — measured at 20 ms per fresh TLS handshake even on the LAN, and 3× the total page time at 120 ms RTT. So never set this on a router whose admin UI is reached over HTTPS or over a slow link.
What did not materialise, so do not repeat these as objections: socket churn cost nothing measurable
— uhttpd burned 3 jiffies per 100 GETs with keep-alive off against 4 with it on, TIME_WAIT sockets
stayed at 0 in both, and conntrack did not grow. The proper fix is TCP_NODELAY in uhttpd, which
keeps keep-alive and removes the stall, and helps every LuCI theme rather than this one.
Dead ends, so nobody re-derives them: HTTPS does not avoid it (56.8 / 44.3 / 44.3 ms over TLS);
uhttpd -h exposes no socket-option knob beyond -k (keep-alive timeout) and -A (TCP keepalive
probes); and the theme cannot work around it from JS — it issues no ubus call of its own during a
navigation (the calls are form.js's session.access and the views' own data), Connection: close
is a forbidden request header, and the connection pool is keyed by origin rather than by URL, so no
cache-buster changes which socket a request lands on.
Running it yourself
# once: environment
python3 -m venv .venv
.venv/bin/pip install playwright
.venv/bin/python -m playwright install chromium
# run (the original theme is restored at the end)
LUCI_PW=<router-root-password> .venv/bin/python bench/nav-benchmark.py \
--ssh-host router --runs 5 --out bench/results-25.12.json
Options: --ssh-host is a host from ssh -G (default router), --runs N sets the number of
measured passes, --out FILE writes JSON, --headful shows the browser. To add a theme,
install it, register it in luci.themes, and append it to THEMES in the script; BASELINE
decides which column the speedup is computed against.
Prepare the router or the numbers will lie:
- Every theme in
THEMESmust be installed and registered inluci.themes. The script only writesluci.main.mediaurlbase, and nothing validates that path: if the theme's template is missing, LuCI quietly falls back to another registered theme, and the column will honestly measure the wrong theme. The run will not fail. It will lie. - An app with invasive CSS changes what is being measured. If a
luci-app-*injects a<style>that paints stock widgets, footstrap contains it per app, but a sheet it cannot attribute still forces a real page load (see spa-router.md). Checkspa_pagesin the JSON: if it is not 38/38, that is why. - The script switches the active theme while it runs and restores it in a
finally. Kill it withkill -9and you may have to setluci.main.mediaurlbaseback by hand.
A run takes themes × (1 warm-up + runs) × pages navigations. With five runs, three themes and
38 pages that is about 680 transitions, or 20 minutes.
Results
Absolute numbers depend on the hardware. The ratios and the request counts are the point.
Real hardware, with CPU (footstrap 0.12.0, 2026-08-01)
OpenWrt 25.12.2, mediatek/filogic, 4 cores, luci-base 26.209. Five runs, median, 38 pages, three
themes, wallpaper off on all of them. Full data in bench/results-25.12.json.
| bootstrap | proton2025 | footstrap | |
|---|---|---|---|
| Time, sum of medians | 11 306 ms | 12 142 ms | 4933 ms |
| vs bootstrap | 2.29× | ||
| median per-page speedup | 3.03× | ||
| pages navigated in place | 0 | 0 | 38 / 38 |
| Router CPU, web stack, whole tour | 37.3 s | 45.7 s | 18.4 s |
| per navigation (polling removed) | 192 ms | 209 ms | 95 ms |
| less router CPU | 2.02× | ||
| tour duration | 62 s | 67 s | 29 s |
| polling rate, parked on Overview | 12 ms/s | 88 ms/s | 14 ms/s |
| load average, end of tour | 0.74 | 1.17 | 0.94 |
| idle baseline, box busy (before / after) | 2.9% / 0.9% |
The router CPU columns answer the obvious suspicion: the speed is not bought with the router's processor. The same tour of 190 navigations costs it 18.4 s of web-stack CPU against 37.3 s.
Client CPU is missing from this run, and the reason is worth knowing. A sample is dropped when the renderer process is replaced mid-navigation, and that does not fall evenly: footstrap kept 38 pages of 38, bootstrap 18 and proton2025 16, because a full page load restarts the renderer far more often than an in-place swap does. What survives for a full-load theme is its cheap navigations, so the cross-theme ratio computed over the 14-page intersection reads as "the SPA theme burns more CPU" when it only means the other themes' expensive samples were thrown away. The script prints that coverage now and says outright that the ratio must not be quoted below 90%. The July run below had full coverage and measured 26.6 ms per navigation against bootstrap's 45.8 ms; that is the figure to use until a run with full coverage replaces it.
Per page, sorted by how much the client router buys:
| page | bootstrap | proton2025 | footstrap | ×bootstrap | requests boot/prot/foot |
|---|---|---|---|---|---|
system/admin/password |
139 ms | 152 ms | 8 ms | 17.15× | 15/28/0 |
system/admin |
134 ms | 149 ms | 10 ms | 14.16× | 15/28/0 |
status/realtime/load |
139 ms | 146 ms | 10 ms | 13.46× | 16/28/1 |
network/diagnostics |
254 ms | 305 ms | 21 ms | 12.25× | 27/38/1 |
status/realtime/bandwidth |
207 ms | 286 ms | 20 ms | 10.39× | 29/39/3 |
status/realtime |
106 ms | 142 ms | 11 ms | 9.60× | 17/28/1 |
status/logs/dmesg |
151 ms | 162 ms | 20 ms | 7.57× | 16/28/1 |
system/admin/dropbear |
322 ms | 319 ms | 58 ms | 5.51× | 32/46/1 |
status/realtime/wireless |
271 ms | 304 ms | 54 ms | 4.98× | 29/41/3 |
status/logs |
199 ms | 254 ms | 41 ms | 4.88× | 17/29/1 |
network/firewall/zones |
311 ms | 333 ms | 79 ms | 3.93× | 32/48/2 |
network/firewall/ipsets |
201 ms | 222 ms | 54 ms | 3.70× | 20/33/1 |
status/logs/syslog |
184 ms | 222 ms | 52 ms | 3.54× | 17/30/1 |
network/routes |
317 ms | 339 ms | 90 ms | 3.54× | 30/41/3 |
network/firewall/rules |
336 ms | 362 ms | 96 ms | 3.49× | 30/42/2 |
network/network |
374 ms | 386 ms | 111 ms | 3.37× | 38/56/4 |
system/crontab |
178 ms | 168 ms | 54 ms | 3.33× | 15/28/1 |
network/firewall |
290 ms | 386 ms | 90 ms | 3.23× | 32/48/2 |
network/dns |
329 ms | 370 ms | 108 ms | 3.05× | 35/53/2 |
network/firewall/forwards |
342 ms | 358 ms | 114 ms | 3.00× | 30/42/2 |
system/system |
415 ms | 383 ms | 139 ms | 2.98× | 33/50/3 |
status/realtime/cpu |
188 ms | 205 ms | 65 ms | 2.89× | 17/29/4 |
network/dhcp |
361 ms | 367 ms | 131 ms | 2.76× | 35/52/3 |
status/realtime/connections |
188 ms | 150 ms | 69 ms | 2.73× | 17/28/2 |
system/admin/sshkeys |
143 ms | 167 ms | 53 ms | 2.69× | 15/27/1 |
system/admin/uhttpd |
142 ms | 166 ms | 56 ms | 2.52× | 16/28/1 |
network/wireless |
389 ms | 391 ms | 156 ms | 2.49× | 34/50/4 |
system/flash |
238 ms | 212 ms | 104 ms | 2.30× | 16/28/2 |
status/overview |
875 ms | 893 ms | 392 ms | 2.23× | 47/72/2 |
system/reboot |
147 ms | 168 ms | 66 ms | 2.22× | 15/27/1 |
network/firewall/snats |
202 ms | 256 ms | 97 ms | 2.09× | 21/34/2 |
system/leds |
207 ms | 238 ms | 106 ms | 1.97× | 26/38/2 |
system/admin/repokeys |
289 ms | 288 ms | 155 ms | 1.87× | 18/30/3 |
status/processes |
291 ms | 314 ms | 157 ms | 1.85× | 15/27/1 |
status/nftables |
214 ms | 245 ms | 128 ms | 1.67× | 18/30/7 |
status/routesj |
232 ms | 266 ms | 174 ms | 1.33× | 20/33/3 |
system/package-manager |
863 ms | 877 ms | 768 ms | 1.12× | 17/31/3 |
system/startup |
1136 ms | 1188 ms | 1016 ms | 1.12× | 15/31/1 |
| TOTAL (sum of medians) | 11306 ms | 12142 ms | 4933 ms | 2.29× | |
| median per-page speedup | 3.03× | ||||
| pages navigated in place | 0 | 0 | 38 / 38 |
The July 2026 run, and why the totals moved
Same hardware, five runs, footstrap 0.11.3, measured 2026-07-26: 11 814 / 12 644 / 4545 ms, 2.60× total and 4.31× median page. So footstrap's total looks 388 ms worse now, while bootstrap's looks 508 ms better.
It is not the theme, and that was measured rather than argued. Both versions were installed on this router in turn, against the same LuCI, the same 38 pages and five runs each:
| footstrap 0.11.7 | footstrap 0.12.0 | |
|---|---|---|
| Time, sum of medians | 4932 ms | 4930 ms |
| Client CPU per navigation | 21.8 ms | 22.5 ms |
| Router CPU per navigation | 97.6 ms | 98.9 ms |
| median per-page difference | 2.8 ms |
What moved is the 40 ms uhttpd stall, the one documented above. Turning it off on this router
(http_keepalive=0, uhttpd restarted, everything else identical) and re-running the same 38 pages
five times:
| footstrap 0.12.0 | sum of medians | client CPU per navigation |
|---|---|---|
http_keepalive=20 (stock) |
4930 ms | 22.5 ms |
http_keepalive=0 |
3886 ms | 20.8 ms |
| 1.27× |
So a fifth of the theme's whole tour is a TCP stall that belongs to the web server. The growth since July tracks it, and tracks the number of requests a navigation makes:
| requests during the navigation | pages | July | keep-alive 20 | keep-alive 0 |
|---|---|---|---|---|
| ≤ 1 | 16 | 1498 ms | 1680 ms | 1475 ms |
| all 38 | 38 | 4545 ms | 4930 ms | 3886 ms |
Read the first row: on the light pages the July run behaves like a router with no stall, and today's behaves like one with it. On the heavier pages July already paid it — Wireless was 127 ms then, 158 ms now and 58 ms with the stall gone; Repository keys 132 / 156 / 58. So July was a mixed state, not a clean baseline, which is the honest reading of it.
luci-base was upgraded on this router on 2026-07-31 (26.134 → 26.209), which is the obvious
suspect and is not the cause: the compare between those two commits touches 300 files and none of
them is luci.js, form.js, uci.js, rpc.js or ui.js, so the set of calls a navigation makes
did not change. Measured during the run with CDP initiator stacks: file.read 42.7 ms on Crontab,
uci.changes 44.6 ms on Dropbear, session.access 44.2 ms on IP Sets — every one issued by
luci.js, none by the theme, against a router that answers such a call in 1–4 ms.
What decides whether a given small call stalls is which socket it lands on, and that is the browser's connection pool, not anything either side of this comparison controls. Why a single-call navigation landed on a fresh socket in July and on a reused one now is not established here; the candidates are the Chromium build the harness downloads and the state of the LAN. It is not the theme (4932 vs 4930 above) and not LuCI's JS (unchanged).
The remedy remains TCP_NODELAY in uhttpd, which keeps keep-alive and helps every LuCI theme.
http_keepalive=0 buys the 1044 ms back today, and must not be set on a router reachable over
HTTPS — this one listens on 443, where the same setting measured 3× worse.
Earlier baseline on the same hardware (footstrap 0.7.16)
Three runs, 38 pages. Kept for comparison: the theme has gained modules and CSS since, and the ratio held. Full data was overwritten by the run above; this table is the record.
| page | bootstrap | proton2025 | footstrap | ×bootstrap |
|---|---|---|---|---|
status/realtime/wireless |
288 ms | 254 ms | 16 ms | 17.48× |
network/diagnostics |
189 ms | 288 ms | 21 ms | 9.15× |
status/realtime/load |
138 ms | 154 ms | 15 ms | 8.96× |
system/admin |
136 ms | 153 ms | 16 ms | 8.82× |
system/admin/sshkeys |
155 ms | 155 ms | 19 ms | 8.15× |
system/admin/password |
108 ms | 155 ms | 14 ms | 7.74× |
status/realtime |
106 ms | 156 ms | 15 ms | 7.04× |
status/realtime/cpu |
190 ms | 168 ms | 28 ms | 6.69× |
system/admin/dropbear |
223 ms | 303 ms | 37 ms | 6.09× |
network/network |
367 ms | 370 ms | 63 ms | 5.81× |
system/crontab |
182 ms | 199 ms | 39 ms | 4.61× |
network/firewall/zones |
302 ms | 306 ms | 66 ms | 4.54× |
network/routes |
350 ms | 308 ms | 78 ms | 4.47× |
system/admin/uhttpd |
153 ms | 172 ms | 35 ms | 4.40× |
status/realtime/bandwidth |
238 ms | 204 ms | 58 ms | 4.12× |
network/firewall/forwards |
343 ms | 356 ms | 87 ms | 3.96× |
network/dns |
328 ms | 398 ms | 84 ms | 3.92× |
status/logs/dmesg |
101 ms | 156 ms | 26 ms | 3.82× |
network/firewall/rules |
300 ms | 353 ms | 87 ms | 3.45× |
network/firewall |
300 ms | 307 ms | 88 ms | 3.41× |
system/system |
348 ms | 393 ms | 113 ms | 3.09× |
network/dhcp |
279 ms | 363 ms | 91 ms | 3.07× |
status/logs/syslog |
189 ms | 221 ms | 64 ms | 2.97× |
network/firewall/snats |
225 ms | 222 ms | 80 ms | 2.81× |
status/logs |
186 ms | 277 ms | 70 ms | 2.64× |
status/routesj |
200 ms | 255 ms | 77 ms | 2.60× |
status/realtime/connections |
139 ms | 154 ms | 56 ms | 2.47× |
network/firewall/ipsets |
168 ms | 219 ms | 69 ms | 2.43× |
network/wireless |
325 ms | 433 ms | 137 ms | 2.36× |
status/nftables |
210 ms | 246 ms | 90 ms | 2.32× |
system/reboot |
140 ms | 154 ms | 63 ms | 2.21× |
system/flash |
244 ms | 221 ms | 113 ms | 2.15× |
system/leds |
237 ms | 269 ms | 117 ms | 2.04× |
system/admin/repokeys |
276 ms | 303 ms | 154 ms | 1.79× |
status/processes |
290 ms | 308 ms | 170 ms | 1.70× |
status/overview |
676 ms | 754 ms | 574 ms | 1.18× |
system/startup |
1076 ms | 1091 ms | 941 ms | 1.14× |
system/package-manager |
814 ms | 881 ms | 765 ms | 1.06× |
| TOTAL (sum of medians) | 10518 ms | 11680 ms | 4638 ms | 2.27× |
| median per-page speedup | 3.43× | |||
| pages navigated in place | 0 | 0 | 38 / 38 |
Dev containers (footstrap 0.10.0, 2026-07-24)
Both dev containers (owlab.yaml), x86 under WSL, so absolute numbers are not
comparable with router hardware. Two themes only: proton2025 is not installed there. Three runs,
median. Data in bench/results-container-2512.json and bench/results-container-2410.json.
| container | release / package manager | pages | footstrap vs bootstrap (sum of medians) | median per page | in place |
|---|---|---|---|---|---|
router2512 |
25.12 / apk | 36 | 2.33× (7458 → 3196 ms) | 3.04× | 34/36 |
router2410 |
24.10 / opkg | 35 | 2.16× (6753 → 3130 ms) | 2.40× | 34/35 |
The page set differs from the hardware run: container fixtures produce different menu entries,
and status/realtime/temperature is skipped because there are no sensors. The ratios line up
with the hardware baseline anyway.