Benchmarks
- Performance: how long a page with 1,400 entries takes to become usable.
- Catching errors: how early each framework reports the same 63 developer mistakes.
Performance
These charts show how long a page with 1,400 rendered entries takes to become usable in Citry and eleven other frameworks. Shorter bars are faster.
- Before the page request
- Server builds the page
- Other request time
- Page download
- Browser work and later requests
- Wait for the next layout
Show the numbers as a table
| Framework | Before the page request | Server builds the page | Other request time | Page download | Browser work and later requests | Wait for the next layout | Total |
|---|---|---|---|---|---|---|---|
| Citry | 0.7 | 107.9 | 0.7 | 7.6 | 161.7 | 17.2 | 295.8 |
| Django + HTMX + Alpine | 0.6 | 63.2 | 0.9 | 1.0 | 274.8 | 15.7 | 356.2 |
| Rusty + HTMX + Alpine | 0.7 | 26.5 | 1.0 | 1.1 | 268.8 | 5.4 | 303.5 |
| Jinja + HTMX + Alpine | 0.6 | 26.4 | 0.7 | 0.7 | 270.5 | 5.3 | 304.2 |
| Tetra | 0.6 | 120.5 | 0.9 | 0.9 | 200.1 | 5.0 | 328.0 |
| django-unicorn | 0.6 | 118.1 | 0.9 | 1.0 | 402.3 | 0.6 | 523.5 |
| django-components | 1.9 | 206.8 | 0.9 | 1.2 | 270.7 | 18.7 | 500.2 |
| FastHTML | 0.8 | 368.2 | 0.3 | 8.5 | 256.6 | 9.0 | 643.4 |
| ReactPy | 0.8 | 2.0 | 0.6 | 0.1 | 841.8 | 31.9 | 877.2 |
| Reflex | 0.8 | 14.9 | 0.5 | 1.9 | 392.2 | 29.6 | 439.9 |
| Next.js | 0.7 | 82.0 | 0.5 | 7.8 | 246.7 | 33.0 | 370.7 |
| Nuxt | 0.6 | 100.4 | 3.6 | 7.6 | 177.1 | 10.1 | 299.4 |
- Before the page request
- Server builds the page
- Other request time
- Page download
- Browser work and later requests
- Wait for the next layout
Show the numbers as a table
| Framework | Before the page request | Server builds the page | Other request time | Page download | Browser work and later requests | Wait for the next layout | Total |
|---|---|---|---|---|---|---|---|
| Citry | 0.6 | 65.3 | 0.8 | 7.6 | 161.0 | 17.3 | 252.6 |
| Django + HTMX + Alpine | 0.6 | 52.8 | 0.9 | 1.7 | 270.4 | 15.5 | 341.9 |
| Rusty + HTMX + Alpine | 0.7 | 16.9 | 0.9 | 1.1 | 265.7 | 8.6 | 293.9 |
| Jinja + HTMX + Alpine | 0.7 | 22.1 | 0.6 | 0.7 | 270.3 | 5.4 | 299.8 |
| Tetra | 0.7 | 117.3 | 0.8 | 1.0 | 199.9 | 5.8 | 325.5 |
| django-unicorn | 0.7 | 102.6 | 0.8 | 0.9 | 394.4 | 9.1 | 508.5 |
| django-components | 0.7 | 198.0 | 0.9 | 1.0 | 268.6 | 5.8 | 475.0 |
| FastHTML | 0.7 | 371.4 | 0.3 | 9.5 | 265.6 | 19.2 | 666.7 |
| ReactPy | 0.6 | 6.0 | 0.5 | 0.1 | 873.7 | 29.4 | 910.3 |
| Reflex | 0.8 | 0.5 | 0.4 | 0.5 | 385.2 | 21.6 | 409.0 |
| Next.js | 0.7 | 51.6 | 0.4 | 7.5 | 240.3 | 23.7 | 324.2 |
| Nuxt | 0.6 | 22.4 | 3.6 | 8.2 | 185.0 | 9.7 | 229.5 |
What the charts show
- Every framework serves the same project page with 1,400 entries: repeated blocks of content, each with its own data.
- A bar runs from the moment the browser asks for the page until the page's scripts respond to input and the browser has laid the page out.
- The first chart times the first request for the page. The second chart times the next request for the same page, after the server has handled it once. The browser cache is empty both times.
- Green is the server building the page. Blue is the browser loading and running scripts after the page arrives. The remaining parts of each bar are short: the wait for the next layout takes up to about 35 ms, and the other phases a few milliseconds each.
- The dashed line marks Citry's total.
- "Rusty" is Django Rusty Templates, an experimental Rust version of Django's template engine.
Where Citry stands
- First load: Citry takes 296 ms. Nuxt, Rusty + HTMX + Alpine, and Jinja + HTMX + Alpine are 3 to 8 ms slower, which is within the noise. Tetra, Django + HTMX + Alpine, and Next.js are 30 to 75 ms slower.
- Second load: Citry takes 253 ms. Nuxt is about 23 ms faster. Rusty + HTMX + Alpine and Jinja + HTMX + Alpine are 40 to 50 ms slower. Next.js, Tetra, and Django + HTMX + Alpine are 70 to 90 ms slower.
- Reflex, django-components, django-unicorn, FastHTML, and ReactPy are slower on both loads.
- Citry spends more time on the server than most frameworks here, and the least time in the browser.
How we measured
- When: Citry on 2026-10-23, the other eleven frameworks on 2026-10-19.
- Which Citry: Citry 0.7.0 before its release, built with citry-core 1.9.0 from the repository at commit 3f13337. The Citry app uses Citry's Vue runtime and
simple="vue". The 2026-10-19 run measured all twelve frameworks with Citry 0.6.0 and citry-core 1.8.0 from PyPI; the charts use that run only for the other eleven. - Where: both runs used the same Apple M4 Mac with Chromium 151, an empty browser cache, and a local connection.
- Same Mac, same times: the 2026-10-23 run also measured Jinja + HTMX + Alpine again, to confirm the Mac gave the same times on both days. Its totals changed by 6 ms on the first load and 13 ms on the second, which is within the noise, so the charts keep its 2026-10-19 numbers.
Each framework reports its own server time, so the work it covers can differ a little. Each bar is a single measurement, so treat a gap of a few tens of milliseconds as noise.
Catching errors
The earlier a mistake is reported, the less time it costs you.
We wrote the same small app in Citry and eleven other frameworks, made the same 63 mistakes in each (a misspelled component name, a wrong-typed input, an unclosed tag, and so on), and recorded where each mistake was first reported. Rusty is the Django app with its templates rendered by Django Rusty Templates, an experimental Rust version of Django's template engine. django-components is the Django app with its page, cards, counter and status line rewritten as components of django-components, the Django library Citry grew out of. ReactPy lets you write React-style components in Python: the server runs them and sends the browser the page to show. django-unicorn is the Django app with its cards, counter, details and search form rewritten as components of django-unicorn. Each click goes to the server, which sends back the component's new HTML. Tetra is the Django app with its interactive parts rewritten as components of Tetra: the server renders each component, and the component's Alpine code in the browser calls its Python methods on the server. Reflex compiles components written in Python into a React app, which sends each click to a Python backend.
- Static/IDE error
- Static/IDE warning
- Runtime error
- Runtime warning
- N/A or prevented
- Missed
Read the error chart
- Static/IDE error (best): the editor, a command-line check, or the production build, before the app runs.
- Static/IDE warning: the same, but only a warning. Easy to miss.
- Runtime error: only once the app runs, from the server, an error response, or the browser.
- Runtime warning: the running app only prints a warning.
- N/A or prevented: no equivalent in this framework, or its design prevents the mistake.
- Missed (worst): nothing reports it, whether or not the page looks different.
- Number on the right: static/IDE errors plus warnings.
- Dashed line: Citry's number.
Where Citry stands
- Before the app runs: 54 of 61 (47 errors, 7 warnings), measured with Citry at commit bf935bc, before the Citry 0.7.0 release. Citry 0.6.0 catches 29 of 61 (see How we measured). Two of the 63 mistakes don't apply to Citry. Next.js 45 of 57, Nuxt 39 of 58, Reflex 38 of 54, ReactPy 24 of 56, FastHTML 20 of 48, django-unicorn 12 of 55, Tetra 9 of 57, Django + HTMX + Alpine 6 of 52, Rusty + HTMX + Alpine 6 of 50, django-components + HTMX + Alpine 6 of 54, Jinja + HTMX + Alpine 5 of 51.
- What Citry checks: when you point
citry checkat your app (citry --app app.main:citry_app check) and set the editor extension'scitry.appsetting to the same app, they check each template against your components, their slots and events, your server events and forms, the CSS variables the server sets, and the pages and files your app serves. They also run Vue's compiler and HTML checks over every template. - Warning only: 7, reported before the app runs, but easy to miss:
- a listener with a misspelled event name: a component event such as
@hdie, or a browser event such as@clik - a CSS variable whose name differs from the one the server sets
- a link to a page that doesn't exist, or an image with a misspelled path: if you ship it, the browser gets a 404
- an image without alt text
- the server passes browser code a value it can't receive, such as a path object. The warning points at the
js_data()line that sends the value rather than the line that creates it
- a listener with a misspelled event name: a component event such as
- Only at runtime: 2:
- a link that passes a word, such as
/projects/alpha, where the route takes a number: FastAPI answers with a 422 validation error. The check knows which pages the app serves, but not the type of a page's parameters - a component reads a value the page shares with
<c-provide>under a misspelled key, such asself.inject("bord")where the page providesboard: the server fails to render the page
- a link that passes a word, such as
- Missed: 5, nothing tells you:
- a misspelled CSS class: the page looks wrong
- the child's event sends a number where the parent expects text: clicking the child's button changes nothing
- a handler that takes more arguments than the event sends: the extra argument is
undefined - the server and browser render different content in a part that passes values to a child component: the page shows the browser's version with no error
- a secret sent to the browser through
js_data(): the page looks right, but the secret is in what the browser receives. No framework here reports this one, and in Next.js and Nuxt even reading a server-only value in browser code puts it in the page before an error appears.
- Why Next.js and Nuxt come next: TypeScript checks Next.js's HTML elements, attribute names and values, and event names. Its server actions take typed arguments, so a wrong or missing field in the data a button sends is caught too. Nuxt's template check, with its
strictTemplatessetting on, reports unknown components, elements, attributes, directives and component events. Both linters report a missing list key, and Next.js's also warns about accessibility mistakes such as a misspelled ARIA attribute. - Why Reflex is close behind Nuxt: its components are typed Python functions and its state fields have types, so Pylance checks component inputs, reads of state fields and event arguments, and
reflex compilechecks prop values, event names and loops before the app runs.
How we measured
- When: 2026-10-21. Every framework's versions are pinned in its app's lockfile; Citry's exception is below.
- Which Citry: Citry and its language server, citry-lsp, built from the repository at commit bf935bc, measured before the Citry 0.7.0 release, with citry-core 1.8.0 from PyPI. The run's report names a later commit that changed only the benchmark, so the Citry code is the same. Citry 0.6.0, the release before that commit, reports 29 of the 61.
- Citry: a FastAPI app that adds Citry's routes with
citry.contrib.fastapi.mount(), as Citry's FastAPI starter does. The editor extension's language server (measured withcitry check --types) andcitry checkcheck it. - Every Python stack (Citry, Django, Rusty, Jinja, Tetra, django-unicorn, django-components, FastHTML, ReactPy, Reflex): Pylance with type checking on (measured with pyright in standard mode), and ruff with its default rules.
- Django, Rusty, Jinja, Tetra, django-unicorn and django-components: djLint with the Django or Jinja profile,
manage.py checkfor every app except Jinja's, and VS Code's JavaScript syntax check for the script files in every app except Jinja's and Tetra's. The Tetra app writes its components' templates and scripts inside Python classes, as Tetra's tutorial does, where djLint and the JavaScript check don't look; its build command,manage.py tetrabuild, reports a script's syntax error but still succeeds, so that counts as a warning. - Reflex:
reflex compile --dry, which builds every page without starting the app. - Next.js and Nuxt: the editor's TypeScript checks (tsc for Next.js; vue-tsc and the Vue compiler's errors for Nuxt), ESLint, and the production build.
- The running app: each framework's server, started as its docs say, driven in Chromium through the same clicks. Server errors, error responses, and browser errors and warnings are recorded.
- Stricter settings: when a framework has a setting that turns on a stricter check, and other frameworks here have the same kind of check, the benchmark turns it on in that framework's app, so the frameworks are compared like for like:
- Jinja:
StrictUndefined, so printing a name the template wasn't given is an error, not empty text. - django-components: Pydantic models for component inputs with the djc-ext-pydantic extension, as its docs show, so a wrong-typed input is an error when the page renders.
- Nuxt: vue-tsc's
strictTemplates, which reports unknown components, elements, attributes, directives and component events, andtypedPages, which checks links that name a route (the page links by path, so it doesn't change the link mistake). - Next.js:
typedRoutes, which checks a link's path against the app's pages, and three stricter TypeScript options that Nuxt turns on by default; they change no result. - ReactPy: debug mode, which checks each element it sends and warns about a list item without a key, like the checks React makes in development.
- Tetra: a type on every public attribute (the values it sends to the browser). Tetra has no setting for this; it checks a value's type only where the attribute declares one.
- Citry, Django, Rusty, FastHTML, django-unicorn and Reflex: none of their settings adds a kind of check the others make, so they keep their defaults.
- Jinja:
- Setup: the project layout from each framework's docs or starter, with default settings unless the benchmark notes say otherwise.
Every mistake
Each cell is a short code for where the mistake was first reported. Cells in the five outcomes are tinted with their chart color. On a computer, hover over a cell to see what the tools printed, or why the mistake doesn't apply.
| Mistake | Citry | Django + HTMX + Alpine | Rusty + HTMX + Alpine | Jinja + HTMX + Alpine | Tetra | django-unicorn | django-components + HTMX + Alpine | FastHTML | ReactPy | Reflex | Nuxt (TypeScript) | Next.js (TypeScript) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| (caught/total) | (56/61) | (21/52) | (23/50) | (32/51) | (25/57) | (25/55) | (27/54) | (30/48) | (35/56) | (42/54) | (44/58) | (49/57) |
| Names: components, inputs, slots, variables | (18/18) | (5/16) | (7/14) | (13/14) | (7/17) | (5/15) | (10/17) | (11/12) | (13/15) | (15/16) | (13/16) | (15/15) |
| Misspelled component name | E | R | R | R | C | R | R | E | E | E | E | E |
| Misspelled input name | E | M | M | R | R | M | R | E | R | E | E | E |
| Missing required input | E | M | M | R | R | M | R | E | R | E | E | E |
| Misspelled slot name | E | M | n/a | n/a | M | n/a | R | E | R | E | M | E |
| Undefined template variable | E | M | M | R | M | M | M | E | E | E | E | E |
| Misspelled attribute on a template object | E | M | R | R | M | M | M | E | E | C | E | E |
| Unknown translation message key | E | M | M | n/a | M | M | M | n/a | n/a | n/a | n/a | n/a |
| Misspelled template directive | E | M | M | M | M | C | M | M | n/a | E | E | n/a |
| Misspelled input on a browser-side component | E | R | R | R | R | M | R | n/a | R | E | E | E |
| Missing required input on a browser-side component | E | n/a | n/a | n/a | n/a | M | n/a | n/a | R | E | E | E |
| Required slot left unfilled | E | M | n/a | R | M | n/a | R | E | R | E | M | E |
| Slot fill reads a misspelled slot-data field | E | n/a | n/a | R | M | n/a | M | E | E | E | E | E |
| Template reads a misspelled state field | E | M | M | R | M | M | M | E | E | E | E | E |
| Browser code reads a misspelled server value | E | R | R | R | R | R | R | n/a | M | E | M | E |
| Link to a route that does not exist | Ew | R | R | R | R | R | R | R | M | M | Rw | E |
| Translation message given a missing or misnamed argument | E | M | R | n/a | M | M | M | n/a | n/a | n/a | n/a | n/a |
| Descendant reads a shared value that no ancestor provides | R | M | M | R | M | M | R | n/a | E | E | Rw | E |
| Unknown template filter or helper function | E | R | R | R | C | R | R | E | E | E | E | E |
| Types | (9/9) | (3/7) | (3/7) | (5/8) | (4/8) | (4/8) | (5/8) | (7/7) | (4/8) | (7/8) | (7/9) | (7/9) |
| Wrong-typed input written in markup | E | M | M | M | M | M | R | n/a | n/a | n/a | E | E |
| Wrong-typed input computed in code | E | E | E | E | E | E | E | E | M | E | E° | E |
| Wrong-typed operation in a template expression | E | M | M | R | M | M | M | E | E | E | M | M |
| Wrong-typed input on a browser-side component | E | n/a | n/a | n/a | n/a | M | n/a | n/a | M | E | E | E |
| Slot fill uses slot data as the wrong type | E | n/a | n/a | R | M | n/a | M | E | R | E | E | E |
| State field given a value of the wrong type | E | M | M | M | E | E | M | E | E | E | E | E |
| Component input given a value outside its allowed choices | E | M | M | M | M | M | R | E | M | E | E | E |
| Loop over a value that is not a list | E | R | R | R | R | R | R | E | E | C | E | E |
| Link passes a route parameter of the wrong type | R | R | R | R | R | R | R | R | M | M | M | M |
| Template and script syntax | (5/5) | (4/5) | (4/5) | (4/5) | (3/6) | (3/5) | (4/5) | (3/4) | (5/6) | (5/6) | (5/5) | (5/6) |
| Syntax error in a browser expression | E | R | R | R | R | R | R | R | E | E | C | E |
| Syntax error in component JavaScript | E | E | E | R | Bw° | n/a | E | R | E | E | E | E |
| Unclosed element in a template | E° | C° | C° | C | M | C° | C° | E | E° | E | E | E° |
| Misspelled path to a template or component file | n/a | R | R | R | R | R | R | n/a | E | E | n/a | E |
| Repeated browser element without a key | E | n/a | n/a | n/a | M | M | n/a | n/a | Rw | M | C | C |
| Two-way binding on an element with no value | E | M | M | M | M | M | M | M | M | C° | E | M |
| HTML and CSS | (12/13) | (4/13) | (4/13) | (4/13) | (4/13) | (5/13) | (4/13) | (3/13) | (3/13) | (6/13) | (9/13) | (10/13) |
| Invalid HTML attribute value | E | M | M | M | M | M | M | M | M | E | E | E |
| Misspelled HTML attribute name | E | M | M | M | M | M | M | M | M | M | E | E |
| Misspelled HTML element | E | M | M | M | M | M | M | E | E | E | E | E |
| Misspelled CSS class | M | M | M | M | M | M | M | M | M | M | M | M |
| CSS reads a misspelled variable the server sets | Ew | M | M | M | M | M | M | M | M | M | E | M |
| Misspelled path to a stylesheet, script or image | Ew | R | R | R | R | R | R | R | R | M | B | R |
| Block element inside a paragraph | E | M | M | M | M | M | M | M | M | R | C | R |
| Image without alt text | Ew | C | C | C | C | C | C | M | M | M | M | E |
| Misspelled ARIA attribute | E | M | M | M | M | C | M | M | M | R | E | Cw |
| Invalid ARIA attribute value | E | M | M | M | M | M | M | M | M | M | E | E |
| Same attribute written twice on one element | E | C | C | C | C | C | C | E | C | E | E | E |
| Two elements share an id | E | C | C | C | C | C | C | M | M | M | M | M |
| Boolean attribute given the text "false" | E | M | M | M | M | M | M | M | M | E | M | E |
| Events and server requests | (10/12) | (3/8) | (3/8) | (4/8) | (5/10) | (6/11) | (3/8) | (4/9) | (9/11) | (9/9) | (8/12) | (10/11) |
| Misspelled server handler name | E | R | R | R | R | M | R | R | E | E | E° | E |
| State field typo in an update handler | E | M | M | R | M | M | M | E | E | R | E | E |
| Misspelled field in a request payload | E | R | R | R | n/a | R | R | R | E | P | M | E |
| Child emits an event the parent does not listen for | E | M | M | M | M | E | M | M | E | E | E | E |
| Child component changes an input it received | E | P | P | P | P | M | P | n/a | C | C | C | C |
| Parent listens for a misspelled event | Ew | M | M | M | M | E° | M | M | R | E | E | E |
| Event sent with a payload of the wrong type | M | n/a | n/a | n/a | R | E | n/a | n/a | n/a | n/a | n/a | n/a |
| Misspelled browser event name on an element | Ew | M | M | M | M | M | M | M | M | C | M | E |
| Request payload field of the wrong type | E | P | P | P | R | P | P | P | E | P | M | E |
| Request payload leaves out a required field | E | R | R | R | R | R | R | R | E | E | R | E |
| Browser reads a misspelled field of the server's reply | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | E | n/a |
| Form field name differs from what the server reads | E | M | M | M | M | M | M | M | M | R | M | M |
| Handler declares arguments the event does not send | M | n/a | n/a | n/a | C | E° | n/a | M | E° | E° | E° | E |
| Server and browser | (2/4) | (2/3) | (2/3) | (2/3) | (2/3) | (2/3) | (1/3) | (2/3) | (1/3) | (0/2) | (2/3) | (2/3) |
| Server-only value read in browser code | E | R | R | R | R | R | M | R | M | M | R | R |
| Server and browser render different content | M | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | R | R |
| Server passes browser code a value the browser cannot receive | Ew° | R | R | R | R | R | R | R | R | P | n/a | n/a |
| Server sends a secret to the browser | M | M | M | M | M | M | M | M | M | M | M | M |
Ereported in the editorCreported by a command-line check, type check or linterBreported by the production buildRreported at runtime by a server error, an error response or a browser error (Rw: only a warning)wafter a letter: that stage gave only a warning, such as Ew for a warning in the editorMmissed: nothing reports it?the runtime check was skipped and no tool reported itPthe framework's design prevents the mistake-no case was written for this frameworkn/athe mistake has no natural equivalent in this framework!the clean app reported problems in this run, so the cell is unreliable~a tool timed out or skipped this case, so it may have caught the mistake earlier°the report points away from the change, and the case explains why it still counts
Related pages
- Performance overview compares the ways to make rendering faster.
- Simple components explains how to render repeated components with less overhead.
- The error benchmark source has every mistake, every framework's app, and the notes on how each tool was set up.