Theme
Version
GitHub PyPI Discord
On this page

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.

First load: time until usable, 1,400 entries
  • 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
First load: time until usable, 1,400 entries, in milliseconds
FrameworkBefore the page requestServer builds the pageOther request timePage downloadBrowser work and later requestsWait for the next layoutTotal
Citry0.7107.90.77.6161.717.2295.8
Django + HTMX + Alpine0.663.20.91.0274.815.7356.2
Rusty + HTMX + Alpine0.726.51.01.1268.85.4303.5
Jinja + HTMX + Alpine0.626.40.70.7270.55.3304.2
Tetra0.6120.50.90.9200.15.0328.0
django-unicorn0.6118.10.91.0402.30.6523.5
django-components1.9206.80.91.2270.718.7500.2
FastHTML0.8368.20.38.5256.69.0643.4
ReactPy0.82.00.60.1841.831.9877.2
Reflex0.814.90.51.9392.229.6439.9
Next.js0.782.00.57.8246.733.0370.7
Nuxt0.6100.43.67.6177.110.1299.4
Second load: time until usable, 1,400 entries
  • 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
Second load: time until usable, 1,400 entries, in milliseconds
FrameworkBefore the page requestServer builds the pageOther request timePage downloadBrowser work and later requestsWait for the next layoutTotal
Citry0.665.30.87.6161.017.3252.6
Django + HTMX + Alpine0.652.80.91.7270.415.5341.9
Rusty + HTMX + Alpine0.716.90.91.1265.78.6293.9
Jinja + HTMX + Alpine0.722.10.60.7270.35.4299.8
Tetra0.7117.30.81.0199.95.8325.5
django-unicorn0.7102.60.80.9394.49.1508.5
django-components0.7198.00.91.0268.65.8475.0
FastHTML0.7371.40.39.5265.619.2666.7
ReactPy0.66.00.50.1873.729.4910.3
Reflex0.80.50.40.5385.221.6409.0
Next.js0.751.60.47.5240.323.7324.2
Nuxt0.622.43.68.2185.09.7229.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.

How the 63 mistakes ended in each framework
  • 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 check at your app (citry --app app.main:citry_app check) and set the editor extension's citry.app setting 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
  • 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 as self.inject("bord") where the page provides board: the server fails to render the page
  • 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 strictTemplates setting 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 compile checks 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 with citry check --types) and citry check check 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 check for 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, and typedPages, 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.
  • 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.

How each mistake ended, by framework
MistakeCitryDjango + HTMX + AlpineRusty + HTMX + AlpineJinja + HTMX + AlpineTetradjango-unicorndjango-components + HTMX + AlpineFastHTMLReactPyReflexNuxt (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 nameERRRCRREEEEE
Misspelled input nameEMMRRMREREEE
Missing required inputEMMRRMREREEE
Misspelled slot nameEMn/an/aMn/aREREME
Undefined template variableEMMRMMMEEEEE
Misspelled attribute on a template objectEMRRMMMEECEE
Unknown translation message keyEMMn/aMMMn/an/an/an/an/a
Misspelled template directiveEMMMMCMMn/aEEn/a
Misspelled input on a browser-side componentERRRRMRn/aREEE
Missing required input on a browser-side componentEn/an/an/an/aMn/an/aREEE
Required slot left unfilledEMn/aRMn/aREREME
Slot fill reads a misspelled slot-data fieldEn/an/aRMn/aMEEEEE
Template reads a misspelled state fieldEMMRMMMEEEEE
Browser code reads a misspelled server valueERRRRRRn/aMEME
Link to a route that does not existEwRRRRRRRMMRwE
Translation message given a missing or misnamed argumentEMRn/aMMMn/an/an/an/an/a
Descendant reads a shared value that no ancestor providesRMMRMMRn/aEERwE
Unknown template filter or helper functionERRRCRREEEEE
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 markupEMMMMMRn/an/an/aEE
Wrong-typed input computed in codeEEEEEEEEMEE°E
Wrong-typed operation in a template expressionEMMRMMMEEEMM
Wrong-typed input on a browser-side componentEn/an/an/an/aMn/an/aMEEE
Slot fill uses slot data as the wrong typeEn/an/aRMn/aMEREEE
State field given a value of the wrong typeEMMMEEMEEEEE
Component input given a value outside its allowed choicesEMMMMMREMEEE
Loop over a value that is not a listERRRRRREECEE
Link passes a route parameter of the wrong typeRRRRRRRRMMMM
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 expressionERRRRRRREECE
Syntax error in component JavaScriptEEERBw°n/aEREEEE
Unclosed element in a templateE°C°C°CMC°C°EE°EEE°
Misspelled path to a template or component filen/aRRRRRRn/aEEn/aE
Repeated browser element without a keyEn/an/an/aMMn/an/aRwMCC
Two-way binding on an element with no valueEMMMMMMMMC°EM
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 valueEMMMMMMMMEEE
Misspelled HTML attribute nameEMMMMMMMMMEE
Misspelled HTML elementEMMMMMMEEEEE
Misspelled CSS classMMMMMMMMMMMM
CSS reads a misspelled variable the server setsEwMMMMMMMMMEM
Misspelled path to a stylesheet, script or imageEwRRRRRRRRMBR
Block element inside a paragraphEMMMMMMMMRCR
Image without alt textEwCCCCCCMMMME
Misspelled ARIA attributeEMMMMCMMMRECw
Invalid ARIA attribute valueEMMMMMMMMMEE
Same attribute written twice on one elementECCCCCCECEEE
Two elements share an idECCCCCCMMMMM
Boolean attribute given the text "false"EMMMMMMMMEME
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 nameERRRRMRREEE°E
State field typo in an update handlerEMMRMMMEEREE
Misspelled field in a request payloadERRRn/aRRREPME
Child emits an event the parent does not listen forEMMMMEMMEEEE
Child component changes an input it receivedEPPPPMPn/aCCCC
Parent listens for a misspelled eventEwMMMME°MMREEE
Event sent with a payload of the wrong typeMn/an/an/aREn/an/an/an/an/an/a
Misspelled browser event name on an elementEwMMMMMMMMCME
Request payload field of the wrong typeEPPPRPPPEPME
Request payload leaves out a required fieldERRRRRRREERE
Browser reads a misspelled field of the server's replyn/an/an/an/an/an/an/an/an/an/aEn/a
Form field name differs from what the server readsEMMMMMMMMRMM
Handler declares arguments the event does not sendMn/an/an/aCE°n/aME°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 codeERRRRRMRMMRR
Server and browser render different contentMn/an/an/an/an/an/an/an/an/aRR
Server passes browser code a value the browser cannot receiveEw°RRRRRRRRPn/an/a
Server sends a secret to the browserMMMMMMMMMMMM
  • E reported in the editor
  • C reported by a command-line check, type check or linter
  • B reported by the production build
  • R reported at runtime by a server error, an error response or a browser error (Rw: only a warning)
  • w after a letter: that stage gave only a warning, such as Ew for a warning in the editor
  • M missed: nothing reports it
  • ? the runtime check was skipped and no tool reported it
  • P the framework's design prevents the mistake
  • - no case was written for this framework
  • n/a the 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