Astro vs Next.js: A TTI Benchmark Workflow

Astro versus Next.js is the most-argued matchup in the islands world, and most of the arguments cite numbers produced by unequal workloads. This workflow removes that ambiguity: you build the same page — same content, same single interactive island — in both frameworks, then measure Time to Interactive and Total Blocking Time under a fixed throttling profile with Lighthouse CI. The output is a defensible head-to-head where the only variable is the framework. It is a concrete instance of the methodology in islands performance benchmarks by framework; the architectural reasons behind the result are unpacked in Next.js App Router streaming patterns.

Prerequisites


Identical Page, Two Frameworks, One Measurement Profile Two parallel lanes. The top lane is the Astro build of the page; the bottom lane is the Next.js build of the identical page. Both feed into the same Lighthouse profile with fixed 4x CPU and devtools throttling, which emits comparable TTI and TBT numbers. The content and the single island are identical in both lanes. SAME CONTENT · SAME ONE ISLAND · SAME PROFILE Astro build static shell + client:load island served from static host Next.js build RSC shell + one 'use client' subtree served from next start Lighthouse profile CPU 4x throttling=devtools 9 runs · median Astro result TTI 1.4s · TBT 70ms Next.js result TTI 2.3s · TBT 210ms

Implementation Steps

Step 1 — Build the identical page in both frameworks

Goal: Produce two pages that differ only in the framework compiling them.

Fix the spec: a fixed-length article with exactly one interactive island — a search box, hydrated on load in both. The Astro version:

---
// src/pages/bench.astro — static shell, one hydrated island.
import SearchBox from '../components/SearchBox.jsx';
const article = await getArticle(); // same data both frameworks use
---
<article set:html={article.html} />

The Next.js version renders the same article as a Server Component and marks the same one island as a Client Component — nothing else hydrates:

// app/bench/page.tsx — RSC shell; only SearchBox carries a client boundary.
import { getArticle } from '@/lib/article';
import SearchBox from './SearchBox'; // 'use client' lives inside this file

export default async function BenchPage() {
  const article = await getArticle(); // identical data to the Astro build
  return (
    <>
      <article dangerouslySetInnerHTML={{ __html: article.html }} />
      {/* The ONLY hydrated subtree — equivalent to Astro's one island. */}
      <SearchBox />
    </>
  );
}

Expected output: Both pages render identical visible content; only the search box is interactive in each.


Step 2 — Serve both from production builds

Goal: Measure optimised output, served consistently.

# Astro — static output served by any static host.
npx astro build
npx serve dist -l 4321 &

# Next.js — production server.
next build
next start -p 3000 &

Expected output: http://localhost:4321/bench (Astro) and http://localhost:3000/bench (Next.js) both serve the finished page.


Step 3 — Run Lighthouse under a fixed throttling profile

Goal: Apply an identical CPU and network model to both apps.

# One function, invoked per app; the ONLY change is the URL.
run_lh () {
  npx lighthouse "$1" \
    --only-categories=performance \
    --throttling-method=devtools \
    --throttling.cpuSlowdownMultiplier=4 \
    --output=json --output-path="$2" \
    --chrome-flags="--headless=new" --quiet
}

for i in $(seq 1 9); do
  run_lh http://localhost:4321/bench "astro-$i.json"
  run_lh http://localhost:3000/bench "next-$i.json"
done

Expected output: Eighteen JSON reports — nine per framework — each containing the interactive and total-blocking-time audits.


Step 4 — Extract TTI and TBT and take medians

Goal: Reduce the raw runs to one defensible number per metric per framework.

// summarise.mjs — median TTI/TBT per framework, robust to warm-up outliers.
import { readFileSync } from 'node:fs';
const median = (xs) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)];

for (const fw of ['astro', 'next']) {
  const tti = [], tbt = [];
  for (let i = 1; i <= 9; i++) {
    const a = JSON.parse(readFileSync(`${fw}-${i}.json`)).audits;
    tti.push(a['interactive'].numericValue);
    tbt.push(a['total-blocking-time'].numericValue);
  }
  console.log(`${fw}: TTI ${median(tti).toFixed(0)}ms  TBT ${median(tbt).toFixed(0)}ms`);
}

Expected output:

astro: TTI 1400ms  TBT 70ms
next: TTI 2300ms  TBT 210ms

Numbers are representative of a one-island content page; your absolute values depend on the machine, but the ratio should be stable.


Interpreting the Gap

The TTI difference on this workload is not a defect in either framework — it is the direct consequence of what each ships. Astro’s static article contributes zero client JavaScript; the only code the browser evaluates is the single search island’s chunk plus its framework runtime, so the main thread reaches quiet almost as soon as the island hydrates. Next.js renders the same article as Server Components that also ship no client JS, but the 'use client' island drags in the React runtime and the reconciliation pass that hydrateRoot performs across that subtree. On a page that is 95% static prose, that fixed hydration cost dominates the comparison, which is why the gap widens as the ratio of static content to interactive content grows.

The corollary matters for how you read the result: this benchmark flatters Astro precisely because the workload is content-heavy with a bounded interactive surface — the scenario islands were designed for. Invert the workload to a dense, mostly-interactive dashboard and the two converge, because Next.js is no longer paying to hydrate a large static tree and Astro’s per-island overhead accumulates across many islands. The honest conclusion is therefore conditional: for content-dominant pages, Astro’s smaller hydration bill produces a materially lower TTI; the moment the interactive surface passes roughly half the page, re-run the harness before assuming the ordering holds. The framework-level reasoning behind that crossover is covered in Next.js App Router streaming patterns, where Suspense boundaries let Next.js stream and defer hydration in ways that narrow the gap.


Verification

  1. Content parity. Diff the rendered HTML of both pages (strip framework attributes). The static article bytes should match to within a few percent; if they do not, the workloads differ and the comparison is biased.
  2. Interactive-surface parity. Confirm both pages hydrate exactly one subtree. Inspect the network waterfall: Next.js should not be shipping hydration for the whole route.
  3. Variance gate. Compute the interquartile range per metric. If it exceeds ~15% of the median, the environment is too noisy — quiet the machine and re-run.
  4. Throttling record. Store the throttling flags next to the numbers. A result without its profile is unreproducible. Cross-reference against the aggregate patterns in islands performance benchmarks by framework.

Troubleshooting

Next.js TTI looks implausibly bad — far worse than 2–3× Astro

Root cause: The whole route is hydrating, not just the island. A 'use client' directive high in the tree (e.g. on the layout or a wrapper) pulls its entire subtree into the client bundle, so you are measuring a misuse, not the framework.

Fix: Push 'use client' down to the leaf that truly needs interactivity — the search box only. Everything above it should remain a Server Component. Re-check the client bundle in the network panel; only the island’s chunk plus the React runtime should ship.

Results reorder when I change the throttling method

Root cause: Lighthouse’s simulated throttling and devtools throttling apply CPU slowdown by different models, and the two frameworks have different long-task shapes, so the model choice can flip the ranking.

Fix: Pin --throttling-method=devtools with a fixed multiplier for both apps and never mix methods within a comparison. Record the method with the results.

The first run of each framework is always the slowest

Root cause: Cold caches, cold JIT, and cold CPU. The first measurement absorbs warm-up cost that is not representative.

Fix: Discard the first run per framework as warm-up and take the median of the rest. Clear the browser cache between runs and randomise which framework you test first across sessions.


← Back to Islands Performance Benchmarks by Framework