cloudflare / cloudflare/vinext

Investigate App Router dynamic SSR performance against Platformatic benchmark

Open
#933 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
8.8k
Forks
406
Avg merge
2d 6h
Merged PRs (30d)
120

Description

I saw this benchmark/tweet and tried benchmarking vinext. There are very clear improvement opportunities for us here.

- Blog: https://blog.platformatic.dev/ssr-framework-benchmarks-v2-corrected-results
- Tweet: https://x.com/matteocollina/status/2036502852642713922
- Benchmark repo: https://github.com/platformatic/k8s-watt-performance-demo/tree/ecommerce

## What This Tests

The benchmark mostly tests **dynamic page SSR under load**.

For vinext/Next.js, that means:

```text
route match -> build RSC tree -> render Flight -> SSR consumes Flight -> HTML
```

It does not meaningfully test API routes, middleware, static assets, hydration, or client navigation.

## Local Docker Harness

I added a local Docker Compose harness as a closer middle ground to the Platformatic setup:

- 6 app containers behind nginx
- k6 in a separate container
- repeated runs with randomized framework order
- warmup before each measured run
- median summary across runs
- reports completed app iterations/sec, not raw HTTP requests/sec
- same benchmark route mix

This is still **not apples-to-apples with Platformatic EKS**. Docker Desktop shares CPU/networking with the load generator. Treat this as a local saturation signal, not publishable framework ranking.

Versions:

- Next.js `16.2.4`, React `19.2.5`
- React Router `7.14.2`, React `19.2.5`, Vite `8.0.10`
- TanStack Start `1.167.50`, TanStack Router `1.168.25`, React `19.2.5`, Vite `8.0.10`
- vinext latest local build from `/Users/nathan/Projects/vinext`, React `19.2.5`, Vite `8.0.10`

## Result: 1000 rps Baseline

One corrected baseline run at `1000 rps`, 6 replicas, 120s measured duration:

| Framework | Success Rate | Achieved rps | Avg Latency | Median | p99 | Dropped |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| TanStack Start | 100.0% | 999.9 | 9ms | 9ms | 20ms | 0 |
| React Router | 100.0% | 999.9 | 10ms | 9ms | 20ms | 0 |
| Next.js | 100.0% | 999.9 | 13ms | 11ms | 47ms | 0 |
| **vinext** | **100.0%** | **993.7** | **351ms** | **45ms** | **11.58s** | **705** |

Interpretation: `1000 rps` is too easy for React Router, TanStack, and Next in this local harness. It already stresses vinext: completed requests return 200, but tail latency spikes and k6 drops scheduled iterations.

## Result: 2000 rps Saturation

Three corrected randomized-order runs at `2000 rps`, 6 replicas, 120s measured duration:

| Framework | Runs | Success Rate | Achieved rps | Avg Latency | Median | p99 | Dropped |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| React Router | 3 | 100.0% | 1999.7 | 13ms | 11ms | 43ms | 0 |
| TanStack Start | 3 | 100.0% | 1999.6 | 16ms | 12ms | 78ms | 0 |
| Next.js | 3 | 100.0% | 1163.7 | 3.71s | 648ms | 33.76s | 84,525 |
| **vinext** | **3** | **100.0%** | **975.5** | **4.52s** | **884ms** | **37.26s** | **109,679** |

Randomized order:

```text
Round 1: react-router next tanstack vinext
Round 2: next tanstack vinext react-router
Round 3: react-router vinext next tanstack
```

Interpretation:

- React Router and TanStack remain clean at `2000 rps`.
- Next.js saturates around ~`1.16k rps` in this local harness.
- vinext saturates lower, around ~`975 rps`, with worse tail latency and more dropped iterations.
- The useful signal is not the absolute numbers. The useful signal is the shape: vinext behaves like the expensive dynamic App Router SSR path and falls over well before loader-style frameworks.

Harness files live in `.docker/` and `docker-compose.local-bench.yml` in the local benchmark checkout.

## Likely vinext Bottleneck

The first suspicious vinext-specific cost is duplicate work in the dynamic App Router page path:

```text
probe page/layouts
-> await async page work when there is no loading.tsx
-> real RSC render executes the page again
-> SSR consumes the Flight stream
-> embed Flight back into HTML
```

Files to inspect:

- `packages/vinext/src/server/app-page-render.ts` - always calls `probeAppPageBeforeRender()` before render
- `packages/vinext/src/server/app-page-probe.ts` - probes layouts and page before render
- `packages/vinext/src/server/app-page-execution.ts` - awaits async page probe results when no loading boundary exists
- `packages/vinext/src/server/app-ssr-entry.ts` - tees Flight and feeds SSR/html embedding

## Done When

- Add timing breakdowns for route match, probe, RSC render, SSR render, and streaming.
- Confirm whether benchmark page data work executes once or twice per request.
- Remove or bypass avoidable probe work for normal `force-dynamic` page SSR if compatible.
- Re-run the benchmark port locally and then in an apples-to-apples EKS setup.

Contributor guide

Open the contributing guide

Research direction

Start with packages/vinext/src/server/app-page-render.ts and trace probeAppPageBeforeRender through app-page-probe.ts and app-page-execution.ts, then inspect app-ssr-entry.ts. Add timing breakdowns for route matching, probing, RSC, SSR, and streaming, and verify whether page data runs once or twice. Re-run the local benchmark using the .docker/ harness and docker-compose.local-bench.yml; done means the avoidable probe cost is confirmed or removed safely and results are compared with the requested EKS setup.

Written by the indexing model from the issue text.

Assessment

Tech stack
docker, next.js, react, typescript
Domain
backend, performance, web-dev
Issue type
Refactor
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.