NoteJul 24, 20264 min read

We made our Vercel builds 5x times faster

Our monorepo had gotten big. A pnpm + Turborepo workspace with a Next.js dashboard, ~40 Cloudflare Workers, and a dozen shared TypeScript packages. And somewhere along the way, "just build it" had quietly turned into a coffee break.

So we sat down, measured everything, and went after it. The worst bottleneck went from 34 seconds to 4 (~7.8×), the full dashboard build dropped ~5×, and typechecking got 2–3× faster, mostly by deleting work rather than adding cleverness. Here's what we found and what we changed.

Rule #1: measure before you touch anything

Before optimizing, we timed a cold run of every stage. The results were lopsided in a useful way:

Two things jumped out. First, generating type declarations was almost the entire cost of building our shared packages, one package, worker-utils, was 33 seconds by itself. Second, the dashboard was type-checking and linting as part of the build, on the critical path, on every single build.

Neither of those is compile work you actually need to wait for.

Fix #1: TypeScript 7 (the native compiler) for typechecking

The easy win first. We swapped tsc for tsgo the

native (Go) TypeScript compiler

shipping as @typescript/native-preview, aka TypeScript 7. It's a drop-in for typechecking:

- Whole-repo pnpm typecheck: 133 s → 57 s

- Dashboard alone: 105 s → 34 s

Same errors, same config, ~2–3× faster. The migration mostly meant deleting a few options TS 7 removed (baseUrl, the legacy node/node10 module resolution), all changes that are perfectly valid on tsc 5.x too, so nothing regressed for anyone still on the old compiler.

The catch: tsgo is still a preview, and it doesn't do emit yet. So it's perfect for local dev and a CI typecheck job, but the bundling that actually ships is still done by Next and Wrangler. Which brings us to…

Fix #2: Turbopack, and getting checks off the critical path

The dashboard build was two problems wearing one trenchcoat.

Problem one: webpack. Turning on Turbopack (next build --turbopack) took the compile from 6.3 min → ~100 s (~3.7×).

Problem two: the build was doing our homework for us. Next runs tsc and ESLint during next build. That's convenient locally, but it means every production build re-does ~2 minutes of type-checking and linting that a separate CI job could do in parallel, and it couples "can we ship the bundle" to "is every lint rule happy."

End to end, the dashboard build went from ~10 min to ~2 min.

Fix #3: the .d.ts tax

This was the interesting one.

Every shared package used tsup with dts: true, which generates bundled .d.ts files via rollup-plugin-dts. That step was slow, and it was ~99% of our package build time.

Then the realization: every one of these packages is internal. They're workspace:* dependencies, never published to npm. Nothing outside the repo ever consumes their compiled .d.ts. And TypeScript is perfectly happy reading types straight from .ts source.

So for most packages, we simply stopped emitting declarations and pointed their types / exports at source:

// packages/{package}/package.json
"main": "dist/index.js",   // runtime still uses built JS
"types": "index.ts"        // types resolved from source

No .d.ts build, identical type information. (One of our packages already did this, we just made it the norm.)

Some packages were external, so instead of dropping declarations, we made them fast. We turned on isolatedDeclarations and generate the .d.ts with:

tsc --emitDeclarationOnly --isolatedDeclarations --noCheck

isolatedDeclarations guarantees each file's declarations can be produced from its explicit types alone no whole-program inference, so emit is cheap and can't silently fall back to any. --noCheck skips re-type-checking dependencies during emit (they're checked in the typecheck job anyway). The price was adding explicit types to ~13 public APIs the compiler flagged. In return:

- external-lib declaration build: 33 s → ~4 s (~7.8×)

Across all packages, build:lib dropped from 74 s → 16 s.

What we learned

- Measure first. Our biggest win was deleting .d.ts generation we never needed, invisible until we timed it.

- Building and checking are different jobs. Keeping them separate made the build faster and let both run in parallel.

- "Internal-only" is a superpower. If nothing outside the repo consumes a package, you can skip a lot of ceremony (compiled declarations, dual formats) and just use source.

- New tools are mostly deletion. TS 7 and Turbopack were fast because they let us remove configuration and steps, not add them.

One short email, every other Sunday.

A few hundred words on what I'm building, a thing I read, and one photograph. No tracking, no upsells, unsubscribe with one click.