---
title: Comparison
description: A measured comparison of @vincle/core with kitajs/html, preact-render-to-string, hono/jsx and react-dom/server, including the method and tradeoffs.
---

# Comparison

Every performance number on this page comes from a benchmark in this
repository. The behavior checks have executable tests alongside the benchmark.

The tree-rendering performance snapshot below was recorded on 16 September
2026 on Linux with an AMD Ryzen 7 PRO 8840HS and Bun 1.4.2,
`@kitajs/html` 5.0.0-next.1,
`preact-render-to-string` 6.7.0, `react-dom` 19.3.0 and `hono` 4.13.8.

## Where Vincle fits

Vincle is a strong fit for server-rendered HTML when you want escaped output by
default, components that can directly await data, and no client-side runtime.
The measurements below put its tree renderer ahead of Preact, React and Hono on
three page shapes, but behind kitajs. Precompilation changes that comparison
when both sides escape the same content.

Choose something else when:

- **You render to the DOM, not to a string.** Vincle has no client runtime, no
  hydration and no reconciler, and none is planned. Preact or React.
- **You need every last microsecond and control your inputs.** `@kitajs/html`
  without `safe` does less, and on Node it stays ahead of Vincle's precompile
  path for it. On Bun the two are level, and Vincle is escaping. With their
  TypeScript plugin wired into CI, either way is a defensible position.
- **You are already inside a framework's rendering pipeline.** `hono/jsx` inside
  a Hono app costs you nothing to keep.

There is no useful single score across speed, security defaults, API features
and ecosystem fit; the right choice depends on which of these your application
needs.

## Escaping is not a setting

The most consequential difference between these libraries has nothing to do with
speed. Given the string `<img src=x onerror="alert(1)">` as a text child:

| Renderer                  | Output                                               |
| ------------------------- | ---------------------------------------------------- |
| `@vincle/core`            | `<div>&lt;img src=x onerror="alert(1)"&gt;</div>`    |
| `react-dom/server`        | `<div>&lt;img src=x onerror=&quot;…&quot;&gt;</div>` |
| `preact-render-to-string` | `<div>&lt;img src=x onerror=&quot;…&quot;></div>`    |
| `hono/jsx`                | `<div>&lt;img src=x onerror=&quot;…&quot;&gt;</div>` |
| `@kitajs/html`            | `<div><img src=x onerror="alert(1)"></div>`          |

`@kitajs/html` does not escape text children unless you ask it to, with a `safe`
attribute or its `escape` helper. This is deliberate on their part, and they
ship [`@kitajs/ts-html-plugin`](https://github.com/kitajs/ts-html-plugin), an
editor plugin and CLI, to catch unescaped interpolations before they reach
production.

Vincle takes the other side of that trade: escaping cannot be turned off from the
component API. The one way to emit unescaped markup is named after what it does:

```tsx
import { raw } from "@vincle/core";

<div>{userInput}</div>        {/* always escaped */}
<div>{raw(trustedHtml)}</div> {/* the only escape hatch, and it is greppable */}
```

Which is the better design is a real argument. What this page settles is that a
benchmark comparing the two defaults is not comparing the same work: see
[below](#reading-the-kitajs-column).

## Attribute names are a list, not a rule

SVG mixes two conventions in one namespace: `viewBox` and `preserveAspectRatio`
are camelCase in the specification, `stroke-width` and `clip-path` are
hyphenated. No transformation separates them: only a list does. Given
`<svg viewBox="0 0 24 24" strokeWidth={2} clipPath="url(#a)" />`:

| Renderer                  | Output                                                                 |
| ------------------------- | ---------------------------------------------------------------------- |
| `@vincle/core`            | `<svg viewBox="0 0 24 24" stroke-width="2" clip-path="url(#a)"></svg>` |
| `react-dom/server`        | `<svg viewBox="0 0 24 24" stroke-width="2" clip-path="url(#a)"></svg>` |
| `hono/jsx`                | `<svg viewBox="0 0 24 24" stroke-width="2" clip-path="url(#a)"></svg>` |
| `preact-render-to-string` | `<svg viewBox="0 0 24 24" strokeWidth="2" clipPath="url(#a)"></svg>`   |
| `@kitajs/html`            | `<svg viewBox="0 0 24 24" strokeWidth="2" clipPath="url(#a)"></svg>`   |

Neither convention is a defect. `@kitajs/html` and Preact pass attribute names
through, so you write the name the markup carries. Vincle, React and Hono accept
the camelCase spelling and map it.

Mixing the two fails silently. The browser reads `strokeWidth` as `strokewidth`,
which is not an SVG attribute, so the stroke keeps its default width: no error,
no warning, and `@kitajs/html`'s types accept both spellings. JSX written against
`@types/react`, icon sets included, uses camelCase throughout.

## Performance

Each competitor is measured in the same process as Vincle. Each ratio below is
the mean of 8 within-process throughput ratios; ± is their sample standard
deviation, not a confidence interval. The values describe this workload and
harness, not every application.

`text` renders 1,000 repeated text blocks, `stack` renders ten trees 1,000
elements deep, and `realworld` renders a full page with 1,000 purchases. Each
measurement includes element creation and HTML rendering; the purchase data is
created before timing.

**Higher is better for Vincle. Below 1 means Vincle is slower.**

| Case        | vs `@kitajs/html` |  vs `preact` |   vs `react` | vs `hono/jsx` |
| ----------- | ----------------: | -----------: | -----------: | ------------: |
| `text`      |      0.60× ± 0.09 | 1.82× ± 0.07 | 6.44× ± 0.62 |  7.78× ± 0.41 |
| `stack`     |      0.48× ± 0.01 | 2.67× ± 0.07 | 2.45× ± 0.07 |  4.47× ± 0.09 |
| `realworld` |      0.73× ± 0.01 | 1.83× ± 0.06 | 2.17× ± 0.06 |  3.79× ± 0.08 |

Against React, Preact and Hono the comparison covers similar work: all three
escape by default and build a tree from the same page shapes. Vincle is between
1.8× and 7.8× faster on these three cases.

### Throughput and time per page

The `realworld` case gives the scale behind those ratios: the mean of 8
fresh-process results, ± the sample standard deviation. Time per page is
derived from throughput, not timed separately.

| Renderer                   |    Pages/s | Time/page |
| -------------------------- | ---------: | --------: |
| `@kitajs/html`             | 2,503 ± 38 |  0.400 ms |
| `@vincle/core` (tree walk) | 1,834 ± 39 |  0.545 ms |
| `preact-render-to-string`  | 1,004 ± 30 |  0.996 ms |
| `react-dom/server`         |   844 ± 18 |  1.185 ms |
| `hono/jsx`                 |   484 ± 12 |  2.066 ms |

These are in-process rendering costs, not HTTP request latency. Absolute times
depend on hardware; the ratios above compare better across machines.

### Reading the kitajs column

Two differences compound in that column.

**It renders a different string.** `realworld` contains no character needing an
escape, so there 0.73× is a fair number. On any input carrying `&` or `<`, Vincle
is doing work kitajs is not.

**It uses a different architecture.** `@kitajs/html` returns a string
immediately; Vincle builds a tree, then walks it. Comparing them measures the
tree, not the renderer.

Vincle has a path with no tree: the [precompile transform](/integration/precompile)
emits templates the way kitajs emits concatenations. Measured on the same
100-row list, with byte-identical output, over 7 fresh processes on each engine:

| Renderer                    |   Bun / JSC |    Node / V8 |                                               |
| --------------------------- | ----------: | -----------: | --------------------------------------------- |
| `@vincle/core` (precompile) | **9.19** µs | **12.44** µs | escapes                                       |
| `@kitajs/html` (`safe`)     |    12.92 µs |     18.06 µs | escapes → Vincle **1.40×** / **1.45×** faster |
| `@kitajs/html` (default)    |     9.21 µs |     11.09 µs | does not escape                               |

So: on the tree-walking path Vincle is slower than kitajs, and on the
comparable path it is faster. Both are true, and neither on its own is the
whole picture.

## Capabilities and runtime cost

| Criterion                                         |           Vincle           | Kita |  Preact  |   Hono   |  React   |
| ------------------------------------------------- | :------------------------: | :--: | :------: | :------: | :------: |
| Escapes a text child by default                   |            yes             |  no  |   yes    |   yes    |   yes    |
| Neutralizes `javascript:` in `href`               |            yes             |  no  |    no    |    no    |   yes    |
| Component returning a `Promise` in the string API |            yes             | yes  |    no    |    no    |    no    |
| Context API                                       | provider + execution scope | none | provider | provider | provider |
| Client DOM rendering / hydration                  |             no             |  no  |   yes    |   yes    |   yes    |
| Direct dependencies of SSR package                |             0              |  1   |    0¹    |    0     |    1²    |

The first three rows are behavior checks on specific inputs, not a general
security or API certification. For the URL row, the input is
`<a href="javascript:alert(1)">x</a>`: Vincle emits `href="#blocked"`; React
substitutes a URL that throws; Kita, Preact and Hono emit the supplied URL. The
dependency row counts direct dependencies from the package manifests, not
package size or the transitive install tree.

For the async row, rendering `<div><Async /></div>` where
`Async` returns `Promise.resolve("hello")` gives:

```text
@vincle/core     <div>hello</div>
@kitajs/html     <div>hello</div>
preact           <div></div>        ← content dropped, no error
hono/jsx         throws "No default value"
react-dom        throws React error #426
```

Preact's `renderToStringAsync` does not change that result: its async support is
for components that _throw_ a promise, not ones that return one. React and Hono
both support async rendering through their streaming entry points
(`renderToReadableStream`, `hono/jsx/streaming`): the row is about the plain
string API.

¹ `preact-render-to-string` declares no direct dependency but requires `preact`
as a peer, so SSR uses two packages.
² `react-dom` depends on `scheduler` and requires `react` as a peer.

Vincle's `createContext` scopes a value to a subtree, as in Preact, Hono and
React; its separate `Scope` stores mutable per-execution state that later
siblings can read. Both stay isolated across concurrent renders. See
[Context & Scope](/api/core/scope). `@kitajs/html` documents
[why it has no context API](https://github.com/kitajs/html#why-there-is-no-context-api).

Beyond the plain string API: streaming and deferred fragments live in
[`@vincle/flow`](/integration/streaming), a separate install; Kita, React and
Hono stream too, each with a different component contract. The
[`@vincle/precompile` transform](/integration/precompile) works with Vincle,
Preact and Hono, not React.

## Reproducing this

```bash
bun run --filter=@vincle/bench bench:stats -- --runs 8
```

It prints the throughput and ratio tables above. A single run is not a
measurement: the protocol, and what counts as a real delta, are in
`apps/bench/README.md`. The like-for-like kitajs measurement is
`apps/bench/src/ab-precompile-kita.js`.
