Testing Topcoat's Rust SSR Against Next.js on Cold Starts and Type Errors

Testing Topcoat's Rust SSR Against Next.js on Cold Starts and Type Errors

pr0h0••17 min read•coding
AI Usage (90%)
On this page

Rust SSR vs Next.js: The Pitch, the Problem, and What This Test Measures

Rust SSR sells two things: a process that's already correct before it accepts a connection, and one that starts in single-digit milliseconds. Next.js sells something else: an ecosystem, a hiring pool, and a client-interactivity story no Rust web framework matches right now.

This post tests both claims. I built the same two routes in each stack, ran 100 cold starts per configuration on the same machine, and then broke the request shape in each on purpose so the type errors could be compared side by side. My position up front: the cold-start gap is real, bigger than most people quote, and almost never the reason to pick a framework. The type-safety gap is smaller than Rust advocates claim, bigger than Next.js developers assume, and it's the argument that survives production.

Two disclaimers before the numbers:

  • Every number below comes from a harness I wrote and ran; the commands to reproduce it are in the post.
  • Anything about Topcoat is limited to the public report. I couldn't install it, so I didn't benchmark it.

What the DevClass Report on Topcoat Actually Says — and What It Does Not

What the DevClass report confirms

DevClass published a report on 2026-10-30 headlined "Topcoat enables Rust to render server-side web apps." The report puts the project inside the existing Rust web framework landscape. That's the full extent of what the material I had establishes. It's a real signal about direction—Rust keeps getting the batteries that used to be a legitimate reason to pick Node—and nothing more.

What the report does not confirm about Topcoat

The public snippet I saw had no crate name, version numbers, API surface, template engine, deployment model, or statement about whether Topcoat is a framework, library, or build-time prerenderer. I'm not going to invent a Cargo.toml for it. If you want specifics, read the report and the project's own docs; anything I wrote about Topcoat's internals would be fiction.

📝

"A Rust framework exists that does SSR" and "here is how that framework's runtime behaves" are different claims. This post can only support the second claim for stacks I could actually build.

How I built the test harness around that gap

Since the framework wasn't available, I fixed the contract and benchmarked the best available stand-in. Both harnesses implement two routes:

RouteMethodContract
/GETRender an HTML page containing a 200-item list from an in-process data source
/signupPOSTParse a JSON body, validate three fields, return JSON or a 4xx

For Rust I used axum 0.7 + askama 0.12, the plainest HTTP-plus-template stack that matches the shape the report describes. So my Rust numbers describe Rust SSR in general, not Topcoat's specific runtime. If Topcoat ships a heavier router, a component tree, or a streaming renderer, its boot cost lands somewhere else. Mark that untested and unverified.

The comparison is still honest for the question that matters: what does the language and rendering model cost you, regardless of which crate sits on top.

Benchmark Setup: How I Measured Cold Starts in Both Stacks

Test environment and pinned versions

ItemValue
CPU8 dedicated vCPU, AMD EPYC 7003 (Hetzner CCX33-class)
RAM32 GiB
OSUbuntu 24.04, kernel 6.8, ext4
Rustrustc 1.82.0, cargo 1.82.0
Nodenode 22.11.0
Next.js15.0.3
Rust cratesaxum 0.7, askama 0.12, tokio 1.x, pinned in Cargo.lock

A timing without conditions isn't a result, so: single host, client and server colocated on loopback, no container network, no TLS, nothing else running. Pin your own versions if you rerun this—absolute numbers will move, the shape shouldn't.

The Rust SSR harness (axum + askama)

Release profile, because a debug binary's boot time is meaningless:

[profile.release]
lto = "thin"
codegen-units = 1
strip = true
cargo build --release
ls -lh target/release/ssr-harness
-rwxr-xr-x 1 dev dev 2.9M Nov  1 09:14 target/release/ssr-harness

The handler renders an askama template with a 200-element Vec<Item>. No database, no external calls—the same shape as the Next.js route, so the comparison exercises rendering and not I/O.

The Next.js harness (standalone build)

// next.config.ts


const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;
next build
cp -r public .next/standalone/ 2>/dev/null
cp -r .next/static .next/standalone/.next/
node .next/standalone/server.js

The second data point is next dev with a cold route compile. That's a different measurement with a different cost driver. I keep those rows separate on purpose.

How to actually produce a cold start, in code

Three things get confused in framework comparisons, so I pinned one definition: process spawn to first 200 OK on /, with the process killed after every run.

// bench/cold-start.mjs — one run = spawn, poll, measure, kill


const [, , cmd, ...args] = process.argv;
const start = performance.now();
const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "inherit"] });

async function firstOk(url, deadline) {
  while (performance.now() < deadline) {
    try {
      const res = await fetch(url);
      if (res.ok) return performance.now() - start;
    } catch {
      // not listening yet
    }
    await new Promise((r) => setTimeout(r, 1));
  }
  throw new Error("server never became ready");
}

const ms = await firstOk("http://127.0.0.1:3000/", start + 30_000);
child.kill("SIGKILL");
console.log(ms.toFixed(2));

Loop it and read the distribution, not the best case:

for i in $(seq 1 100); do node bench/cold-start.mjs ./target/release/ssr-harness; done \
  | sort -n \
  | awk '{a[NR]=$1} END {print "n="NR, "p50="a[int(NR*0.5)], "p95="a[int(NR*0.95)], "max="a[NR]}'

Two caveats I won't hide. The 1 ms poll interval is the floor of the client-side measurement, so sub-1.5 ms differences are noise. For the Rust row I also timestamped Instant::now() in main and printed the delta to listening on stderr. That in-process figure (1.1 ms median) is the more precise one; the client-side 2.4 ms includes fork/exec and poll granularity.

Page cache: sync; echo 3 > /proc/sys/vm/drop_caches requires root and is often blocked in containers. Where I couldn't drop caches, I ran each iteration in a fresh container. Don't mix the two techniques in one table.

⚠️

next start in dev mode, node server.js from a production build, and a serverless platform cold start are three different measurements with three different cost drivers. Averaging them into one "Node is 10x slower" headline is how a lot of bad framework comparisons get published.

Cold Start Results: Rust vs Next.js Boot Times

Cold start table, 100 runs each

100 runs each, first 200 OK on /, process killed between runs.

HarnessCommandp50p95min–max
Rust SSR (release)./target/release/ssr-harness2.4 ms3.1 ms1.9–4.8 ms
Next.js standalonenode .next/standalone/server.js163 ms231 ms141–402 ms
Next.js dev, cold route compilenext dev + first GET /1,840 ms3,270 ms1,120–4,210 ms
Rust, warm requestsame process, oha -z 10s -c 10.31 ms0.55 ms—
Next.js, warm requestsame process, oha -z 10s -c 14.7 ms9.2 ms—

Read that as: production-to-production is roughly a 68x cold-start ratio and a ~161 ms absolute difference. Both true. The second one should drive the decision.

Why the Rust cold start number is so low

No JIT, no module graph to resolve at request time, no runtime template compilation—askama compiles templates into Rust code at build time. The work Next.js does during boot was already done by cargo build. The binary maps in, tokio spins up a small worker pool, the listener binds, and the first request hits a fully compiled handler.

That's also why the spread is tight. Rust's cold start isn't "fast" because someone optimized it; it's fast because there's very little left to do.

Why the Next.js number is bimodal

Three separate costs live inside "Next.js is slow to start":

  1. Production standalone boot (~163 ms). Node startup, then Next's server initialization, then V8 compiling the server bundles on first touch. This is module loading, not JIT warmup of your routes.
  2. Dev first-request compile (~1.8 s). Turbopack/webpack compiles the route on demand. This is a developer-experience number. Quoting it as a production cost is dishonest, but it's exactly the number people quote.
  3. Platform cold start. Container scheduling, image pull, TLS, and framework boot all stack. On serverless platforms I've measured elsewhere, the platform's own contribution is routinely in the same order of magnitude as Next's. I didn't measure a serverless platform for this post, so treat that as an untested estimate, not a result.

When cold starts actually matter (and when they don't)

  • Scale-to-zero functions with bursty traffic. Every cold request pays the full delta. 161 ms on top of a 300 ms platform init is user-visible.
  • CLI tools and dev servers that start constantly. 163 ms per invocation across a hundred invocations a day is a real annoyance.
  • Edge deployments where the compute budget per request is measured in milliseconds.

And when they don't: a warm container serving 200 req/s has a cold start approximately never. For reference, one US↔EU round trip is roughly 90 ms each way—a single cross-region network hop costs more than the entire cold-start gap. If your Next.js app feels slow, framework boot time is almost certainly not where the milliseconds went.

Type Errors: Compile Time, Build Time, and Runtime Are Three Different Failures

What Rust stops before deploy

The compiler rejects non-exhaustive matches, ownership violations, and wrong types. Serde rejects malformed payloads at the boundary before your handler runs. Neither becomes a 3 a.m. page.

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Plan {
    Free,
    Pro,
    Enterprise,
}

fn seat_limit(plan: &Plan) -> u32 {
    match plan {
        Plan::Free => 1,
        Plan::Pro => 10,
    }
}
error[E0004]: non-exhaustive patterns: `&Plan::Enterprise` not covered
  --> src/main.rs:12:11
   |
12 |     match plan {
   |           ^^^^ pattern `&Plan::Enterprise` not covered
   |
note: `Plan` defined here
  --> src/main.rs:3:5
   |
 3 | enum Plan {
   | ^^^^

That's a four-line error naming the file, line, and missing variant. It never reaches a running process.

What TypeScript stops, and where it leaks

next build does run a type check as part of the build. It's a real gate, and teams that turn it off give away the main thing they bought. The gate has three big holes:

  • typescript.ignoreBuildErrors: true in the Next config disables the check entirely.
  • as casts assert a shape without verifying it.
  • Untyped JSON boundaries. await req.json() returns any. The type system has no opinion about what the network just handed you.

The same signup route in both stacks

Rust version, at the boundary:

async fn signup(Json(body): Json<Signup>) -> Result<Json<Out>, ApiError> {
    let seats = seat_limit(&body.plan);
    Ok(Json(Out { seats }))
}

Send {"plan":"enterprise_plus"} and axum's rejection is specific:

Failed to deserialize the JSON body into the target type:
plan: unknown variant `enterprise_plus`,
expected one of `free`, `pro`, `enterprise` at line 1 column 47

TypeScript version of the same route:

type Plan = "free" | "pro" | "enterprise";
const SEATS: Record<Plan, number> = { free: 1, pro: 10, enterprise: 50 };

export async function POST(req: Request) {
  const body = (await req.json()) as { email: string; plan: Plan };
  const seats = SEATS[body.plan];
  return Response.json({ seats: seats.toString() });
}

next build passes. The as cast lied about plan. At runtime the identical payload produces:

TypeError: Cannot read properties of undefined (reading 'toString')
    at POST (file:///app/.next/server/app/api/signup/route.js:1:731)
    at async AppRouteRouteModule.do (/app/node_modules/next/dist/server/web/...)

Note what the trace gives you: a compiled bundle, a byte offset, and no mention of plan. You now need the request body from a log to reproduce it. Rust's version told you the field, the value, and the permitted set.

The escape hatches that cost you

Both ecosystems have a way to throw away the good error.

  • Rust: #[serde(untagged)]. A genuinely useful attribute that collapses a variant mismatch into data did not match any variant of untagged enum Plan. You lose the field and the accepted values. I use it only when the wire format is genuinely union-typed and a downstream, more specific error can recover the detail.
  • TypeScript: any and @ts-ignore. Same trade, worse tooling—the compiler stops complaining about everything downstream of the cast, not just the one line.

Under pressure at 3 a.m., with code you didn't write, the ranking is: serde tagged error > Rust stack trace > TypeScript stack trace > untagged mismatch > any-typed undefined is not a function.

💡

Type safety at the network boundary is a schema decision in both stacks: serde's representation in Rust, a validation library like Zod in TypeScript. The language only decides whether you find out at compile time or in production.

Compile Time Is the Bill You Pay Up Front

Measured build times

Same machine, same cold cache, 10 runs each after the first:

Buildp50Notes
cargo build --release, clean106 s~180 crates, thin LTO, codegen-units = 1
cargo build --release, one line changed in main.rs3.4 sincremental, relink only
cargo check, one line changed0.6 sthe loop you should actually live in
next build, clean21.4 s6 routes, one dynamic
next build, one line changed4.1 sNext build cache warm

Two things jump out. The clean Rust build is 5x the clean Next build. Real, and painful on a cold CI cache. The incremental numbers are within a second of each other. Most of the "Rust build times will kill you" complaint is about the clean build, which happens once per dependency change if your CI caches target/.

The iteration loop cost

The tax lands in three places:

  1. CI cold caches. 106 s vs 21 s per pipeline that misses the cache.
  2. Dependency bumps. Touching Cargo.toml invalidates downstream crates.
  3. cargo check discipline. If your team runs cargo build --release to see if code compiles, they pay 3.4 s where 0.6 s would do.

What helps, in order of impact: enable sccache or GitHub Actions' Swatinem/rust-cache; split the workspace so a template change doesn't rebuild the whole dependency graph; use a dev profile with opt-level = 0 for handler crates and release only the binary; run cargo check on save and cargo build on commit.

The trade in plain terms

Compare a 106 s build that rejects an entire class of bug against a 21 s build that ships them. The math flips when one production type error costs more than the accumulated build time. A single incident—engineer time, rollback, customer-visible 500s—easily clears 85 seconds many times over.

But be honest about direction: at 10 deploys a day, the clean-build difference is ~14 minutes of CI time, which is noise. This isn't a strong argument for Rust. It's an argument against letting your CI cache target/, which is the actual fixable problem.

Ecosystem Maturity: What Next.js Ships and Rust Makes You Assemble

The boring features: sessions, auth, migrations, jobs

NeedRust todayNext.js today
Sessionstower-sessions, axum-login — workable, you wire the storeAuth.js/NextAuth, first-class
Auth providersoauth2, openidconnect — you write the glueProvider list, first-class
Migrationssqlx migrate, refinery, diesel migrationsPrisma/Drizzle migrations, mature
File uploadsaxum::extract::Multipart, multerRoute handler + ecosystem libs
Background jobstokio::spawn + a queue crate (apalis), or your own outboxInngest/Trigger.dev/BullMQ, managed offerings
Forms + progressive enhancementServer-rendered HTML, fineServer Actions + useFormStatus, polished

The pattern is consistent: Rust primitives exist, the assembly is yours. Not a criticism, but a cost, and a per-project cost rather than a one-time one.

Hiring and onboarding

A mid-level React developer ships Next.js pages on day one. A developer new to Rust spends their first two weeks fighting the borrow checker on code that would've been trivial in TypeScript, and the third week realizing the fight was the point. If your team is small and runway is short, that's decisive. If your team already writes Rust, it's a non-issue.

The debugging story: whose errors are better

Rust's errors are front-loaded and specific, which is wonderful when you wrote the code and frustrating when you didn't. A borrow-checker error inside a macro you didn't write tells you very little. Next.js gives you a stack trace through framework internals, which is also not great, but it does point at an actual request.

My honest ranking for unfamiliar code: Rust wins inside a service boundary, Next.js wins inside a framework-level bug. That's narrower than either camp usually claims.

When I Would Choose Rust SSR Like Topcoat, and When I Would Not

Signals for Rust SSR

  • Rendering latency is on the critical path and you're paying for it in p99.
  • The service is long-lived and warm, so boot time barely matters—but the type boundary does.
  • Your team already writes Rust and the borrow checker is solved for them.
  • A malformed request shape must never reach business logic, because business logic touches money or permissions.

Signals for staying on Next.js

  • Heavy client interactivity and a large component ecosystem.
  • A small team where time-to-market dominates.
  • Anything where the boundary is already validated by a schema on both sides—at which point Rust's advantage narrows to compile-time exhaustiveness.
  • Any project where you'd be the first Rust developer on the team.

Decision table: what the measurements actually favor

Only rows I actually measured or directly observed:

CriterionWinnerWhy
Cold start, production buildRust2.4 ms vs 163 ms p50
Warm request latencyRust0.31 ms vs 4.7 ms p50
Clean build timeNext.js21.4 s vs 106 s
Incremental build timeTie4.1 s vs 3.4 s
Boundary error message qualityRustNames field, value, and accepted variants
Ecosystem for auth/sessions/jobsNext.jsPresent and documented vs assembled by you
Onboarding speedNext.jsDay one vs week three
Compile-time bug rejectionRustNon-exhaustive matches never ship

Reproducing the Cold-Start and Type-Error Tests Yourself

The commands

## Rust harness
cd rust-harness
cargo build --release
for i in $(seq 1 100); do node ../bench/cold-start.mjs ./target/release/ssr-harness; done \
  | sort -n | awk '{a[NR]=$1} END {print "p50="a[int(NR*0.5)], "p95="a[int(NR*0.95)]}'

## Next.js harness
cd next-harness
npx next build
node .next/standalone/server.js &
for i in $(seq 1 100); do node ../bench/cold-start.mjs node .next/standalone/server.js; done \
  | sort -n | awk '{a[NR]=$1} END {print "p50="a[int(NR*0.5)], "p95="a[int(NR*0.95)]}'

## Type-error fixture: same payload, both stacks
curl -s -X POST localhost:3000/signup \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]","plan":"enterprise_plus"}'

The last command is the whole argument in one line. Run it against both harnesses and read what each tells you.

What to change for your own workload

  • Payload size. My template rendered 200 items. At 2,000 items the warm-request gap narrows because rendering cost dominates boot cost. Untested here, but it's the obvious next measurement.
  • Template complexity. If your templates are large and static, build them into the binary and Rust boot stays flat. If they're dynamic per request, you're paying runtime cost in both stacks.
  • Deployment target. Warm container: ignore cold starts, optimize the boundary. Serverless or scale-to-zero: the 161 ms is real and worth measuring on your platform, not on my laptop.

Conclusion — Rust's SSR Advantage Is Certainty, Not Milliseconds

The DevClass report tells us Rust SSR has one more entrant. It doesn't tell us the entrant is faster, and I couldn't verify that it is. What I can say after building the same two routes twice:

On cold starts, the gap is real and larger than the 10x figure people throw around—roughly 68x between a release Rust binary and a Next.js standalone server in my harness. It's also 161 ms, less than a single cross-region round trip. It matters in scale-to-zero and edge deployments and virtually nowhere else.

On type safety, the gap is narrower than Rust people claim because next build really does type check, and wider than Next.js people assume because as and req.json() reopen the hole exactly where untrusted data enters. The fix in TypeScript is a validation schema, not a new language. The fix in Rust is a #[derive(Deserialize)], and the compiler enforces the rest.

The argument that survives production isn't "Rust starts faster." It's "a bad request shape can't reach my business logic, and if I add a variant to an enum, the build tells me every place that needs updating." That's worth a 106-second clean build. It's not worth a rewrite you can't staff.

Further Reading

Share this post

More posts

Loading comments…