金数据技术博客 · №17

Solving GraphQL Type Explosion to Significantly Improve Turbopack Build Performance

· flanker · frontend / nextjs / GraphQL · 中文版

Chinese Version 中文版

Background

Jinshuju is a SaaS product for online forms. Our backend is built with Ruby on Rails and exposes APIs via GraphQL, while the frontend is a fairly large Next.js application.

The product has been running for more than ten years, and the business domain is highly complex. This is one of the key reasons we chose GraphQL in the first place: its type system.

GraphQL enforces strict type definitions for APIs. Combined with TypeScript on the frontend, we are able to catch a large number of potential issues at compile time.

Another practical reason is that we need to serve multiple frontend clients with the same API layer:

GraphQL fits this requirement extremely well.

Of course, there is a trade-off.
Our main frontend codebase has grown quite large, with a substantial number of routes and a significant amount of code.

A New Problem Introduced by Turbopack

As the project continued to grow, Webpack gradually became a bottleneck during local development:

This had a direct and painful impact on development efficiency.
At its core, developer experience is about feedback speed.

The good news was that we recently upgraded to Next.js 16 and officially switched to Turbopack.

The migration was smoother than expected, and the improvements were obvious:

The productivity boost was significant 👍

But soon after, a serious issue surfaced.

“The First Request Takes 70 Seconds?”

During local development, after starting the dev server and opening the first page, something felt very wrong.

It was slow.
Painfully slow.

$ pnpm dev

 ▲ Next.js 16.0.10 (Turbopack)

 ✓ Starting...
 ✓ Ready in 2.7s
 ○ Compiling /home ...
 GET /home 200 in 79s (compile: 78s, proxy.ts: 336ms, render: 1055ms)
 ○ Compiling /favorites ...
 GET /favorites 200 in 5.4s (compile: 5.4s, proxy.ts: 9ms, render: 17ms)
 GET /home 200 in 49ms (compile: 18ms, proxy.ts: 15ms, render: 17ms)

Let’s break this down:

In other words:

Only the very first request was extremely slow.

More than 70 seconds.

At that moment, I genuinely didn’t know what to do during that wait:
make a cup of coffee?
or just stare at my coworkers in silence?

First Attempt: Circular Dependencies

My first instinct was that the problem might be caused by circular dependencies.

We don’t enforce extremely strict linting rules, and given the age of the project, it’s not surprising that some circular dependencies slipped in. From a compiler’s perspective, circular dependencies rarely sound like a good thing.

So I ran madge to check:

$ npx madge --circular --extensions ts,tsx src/

Processed 3722 files (27.8s) (1544 warnings)

✖ Found 302 circular dependencies!


Over three hundred.

That number was honestly shocking.

Circular dependencies tend to cause several issues:

From an engineering standpoint, they should be cleaned up anyway.

We did spend time refactoring and extracting shared modules to break these cycles.

But the result was disappointing.

The first compile time did not improve at all.

It seems Turbopack already handles circular dependencies reasonably well.
While circular dependencies are worth fixing, they were not the root cause of this performance issue.

Second Attempt: Turbopack Tracing

Guessing clearly wasn’t enough. It was time to use proper tooling.

Next.js provides an official tracing feature for Turbopack:

https://nextjs.org/docs/app/guides/local-development#turbopack-tracing

By adding an environment variable when starting the dev server:

$ NEXT_TURBOPACK_TRACING=1 pnpm dev

We reproduced the issue once again — still more than 70 seconds for the first request.

This time, however, Turbopack generated a tracing file at:

.next/dev/trace-turbopack

The file is binary and not human-readable. Next.js provides an internal command to inspect it:

$ npx next internal trace .next/dev/trace-turbopack

Then you can view the trace in the browser at:

https://trace.nextjs.org/

(Using a local trace file with an online UI is a bit magical, to be honest 😂)

The Flame Graph Revealed the Truth

On the tracing page, switching the view to “Span in Order” shows the full compilation flame graph.

Image

The problem immediately became obvious:

Compiling /(shell)/(system)/(withHeader)/(dashboard)/home/page
took 70.36 seconds

Digging into the details revealed more:

Image

Expanding the call stack further exposed the real culprit.

At the deepest and slowest branches:

Image

ReduxProvider was loading domain.ts

Specifically:

Just parsing this single file consumed 52 seconds.

What Is domain.ts?

In short, domain.ts is a type file automatically generated by GraphQL Codegen.

We want the frontend to fully leverage GraphQL’s type system and maximize TypeScript’s inference capabilities, so we centralize all generated GraphQL types into this file.

Its structure looks roughly like this:

export type Form = {
  id: string
  title: string
  createdAt: Date
  ...
}

export type User = {
  id: string
  name: string
  email: string
  ...
}

At first glance, nothing seems wrong.

Until I checked the file size:

$ ls -la src/typings/domain.ts
14M Dec 16 10:33 src/typings/domain.ts

14 MB.

Parsing a 14 MB TypeScript type file in ~26 seconds is actually quite reasonable.

Why Did It Become So Large?

Our GraphQL Codegen configuration looked like this:

schema: http://localhost:3000/graphql
documents:
  - 'src/lib/api/graphql/**/*.graphql'
generates:
  src/typings/domain.ts:
    plugins:
      - 'typescript'
      - 'typescript-operations'

A quick explanation of these plugins:

This is a very common and seemingly reasonable setup.

At first, I suspected that using both plugins caused excessive duplication.
So I tried keeping only typescript-operations.

The result?

$ ls -la src/typings/domain.ts
13M Dec 16 11:14 src/typings/domain.ts

Only slightly smaller.

Clearly, this wasn’t the real issue.


The Real Problem: Fragments Were Fully Inlined

Only after carefully inspecting the generated domain.ts again did I notice the real issue:

Fragment types were being fully inlined everywhere.

In our codebase, we heavily use GraphQL Fragments to reuse fields, for example:

fragment FormBasic on Form {
  id
  title
  createdAt
}

This fragment is used in multiple operations such as GetForm, CreateForm, and UpdateForm.

However, in the generated types, it looked like this:

export type GetFormQuery {
  form: {
    id: string
    title: string
    createdAt: Date
  }
}

export type CreateFormMutation {
  form: {
    id: string
    title: string
    createdAt: Date
  }
}

export type UpdateFormMutation {
  form: {
    id: string
    title: string
    createdAt: Date
  }
}

Every usage of a fragment resulted in the same fields being expanded again.

In a system with complex business logic, many fields, and extensive fragment reuse,
this almost guarantees type file explosion.

The Solution: inlineFragmentTypes = combine

GraphQL Codegen actually provides a solution for this.

In the documentation, I found this configuration option:

https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-operations#inlinefragmenttypes

inlineFragmentTypes: 'combine'

Its meaning is straightforward:

We updated our configuration as follows:

schema: http://localhost:3000/graphql
documents:
  - 'src/lib/api/graphql/**/*.graphql'
generates:
  src/typings/domain.ts:
    plugins:
      - 'typescript-operations'
    config:
      inlineFragmentTypes: 'combine'

Then we regenerated the types.

Immediate Results

The generated types finally matched our expectations:

export type FormBasicFragment {
  id: string
  title: string
  createdAt: Date
}

export type GetFormQuery {
  form: FormBasicFragment
}

export type CreateFormMutation {
  form: FormBasicFragment
}

export type UpdateFormMutation {
  form: FormBasicFragment
}

And the file size?

$ ls -la src/typings/domain.ts
1.4M Dec 16 11:39 src/typings/domain.ts

14 MB → 1.4 MB

One tenth of the original size.

Restarting the dev server:

GET /home 200 in 17.1s (compile: 15.8s)

From over 70 seconds down to 17 seconds.

Yes, 17 seconds is still not ideal.
But for a project of this scale, it’s a meaningful and very welcome improvement 🎉

More importantly, parse domain.ts was no longer a bottleneck in the new Turbopack trace.

Image

Follow-Up and Thoughts

This optimization:

We ran all checks:

Everything passed.

It’s worth noting that the Codegen documentation clearly states that:

In most cases, inline is the safer default.

The combine mode can cause type issues in deeply nested or complex list scenarios.
We’ll continue to monitor this and plan to write a follow-up once we have more experience.

That said, for our current setup, it solved a very real problem:

Uncontrolled growth of type files can severely slow down the build system.

Summary

One line of configuration, a significantly better developer experience for the entire team.

If you’re seeing a similar “extremely slow first request” issue with Turbopack,
this is a great place to start investigating.