Skip to content

sourceMap emit is disproportionately slow for files containing one very large object literal #64134

Description

@ken7253

🔎 Search Terms

sourceMap emit slow, emitSourceMapsBeforeNode, emitSourceMapsAfterNode, source map performance native port, large object literal emit

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about source map emit performance

Measured on typescript@next (7.1.0-dev.20260902.1), so this is not something already fixed on main. It is also present in 7.0.2 at the same magnitude.

This is not a regression within 7.x. It is a difference between Corsa and TypeScript 6 on the same input.

For context, typescript-go#2265 reported source map work running even with maps disabled, and was closed by typescript-go#2872 ("Speed up source map emit with printer-local monotomic cache", merged 2026-02-23). That is a different case from this one, where maps are enabled. It is worth mentioning only because #2872 touched source map emit, and the versions measured below both include it.

  • tsc --version → 7.1.0-dev.20260902.1 (typescript@next); also checked on 7.0.2
  • tsc6 --version → 6.0.3
  • Node v24.18.0
  • macOS, arm64 (Apple Silicon)
  • --singleThreaded is passed to the native tsc runs to remove scheduling noise; the effect reproduces without it

The affected input is produced by graphql-code-generator:

  • @graphql-codegen/cli 6.1.2
  • @graphql-codegen/typed-document-node 6.1.8
  • @graphql-codegen/typescript-operations 5.0.8
  • @graphql-codegen/near-operation-file-preset 5.1.0
  • graphql 16.13.2

⏯ Playground Link

N/A. This is a wall-clock difference during emit, so it needs an actual tsc run over files on disk.

💻 Code

This is a generator script rather than a pasteable sample, for two reasons. The input that triggers the problem is a single ~38 KB line, which is not readable inline in an issue. And the symptom is emit time rather than a wrong output, so it has to be measured against real files instead of read off a snippet. The script is around 50 lines, has no dependencies, and writes both the sources and the tsconfig, so setup is one command.

// make-repro.mjs
import fs from "node:fs";
import path from "node:path";

const OUT = process.argv[2] ?? "/tmp/tsgo-repro";
const FILE_COUNT = Number(process.argv[3] ?? 300);
const TARGET_KB = Number(process.argv[4] ?? 36);

fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(path.join(OUT, "src"), { recursive: true });

// A deeply nested object literal, emitted on a single line.
const node = (depth, i) =>
  depth === 0
    ? `{"kind":"Name","value":"leaf${i}"}`
    : `{"kind":"Field","name":{"kind":"Name","value":"f${i}"},"selectionSet":{"kind":"SelectionSet","selections":[` +
      `${node(depth - 1, i * 2)},${node(depth - 1, i * 2 + 1)}]}}`;

const buildLiteral = (targetBytes) => {
  const parts = [];
  let size = 0;
  for (let i = 0; size < targetBytes; i++) {
    const s = node(4, i);
    parts.push(s);
    size += s.length + 1;
  }
  return `{"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","selectionSet":{"kind":"SelectionSet","selections":[${parts.join(",")}]}}]}`;
};

const literal = buildLiteral(TARGET_KB * 1024);

for (let f = 0; f < FILE_COUNT; f++) {
  fs.writeFileSync(
    path.join(OUT, "src", `doc${String(f).padStart(4, "0")}.ts`),
    `export const doc${f} = ${literal};\n`
  );
}

fs.writeFileSync(
  path.join(OUT, "tsconfig.json"),
  JSON.stringify(
    {
      compilerOptions: {
        target: "ES2022", module: "esnext", moduleResolution: "bundler",
        outDir: "./dist", rootDir: "./src", strict: true,
        declaration: true, declarationMap: true, sourceMap: true,
        isolatedModules: true, skipLibCheck: true, noEmit: false,
      },
      include: ["src/**/*.ts"],
    },
    null, 2
  ) + "\n"
);

const bytes = fs.statSync(path.join(OUT, "src", "doc0000.ts")).size;
console.log(`${OUT}: ${FILE_COUNT} files / ${(bytes / 1024).toFixed(1)} KB each / one ${literal.length}-char line`);
node make-repro.mjs /tmp/tsgo-repro
cd /tmp/tsgo-repro
tsc --project tsconfig.json --singleThreaded
tsc --project tsconfig.json --singleThreaded --sourceMap false --declarationMap false

The file count is the second argument and defaults to 300 so the difference is unmistakable. The cost turns out to be per file (see below), so a smaller count shows the same effect at proportionally smaller magnitude — node make-repro.mjs /tmp/tsgo-repro 10 is enough to see it without waiting half a minute.

🙁 Actual behavior

All numbers below come from the generator above at its default 300 files, so they are reproducible as-is. Every compiler gets the identical tsconfig and identical input. Wall clock, warmed: one discarded run, then the faster of two.

compiler sourceMap declarationMap wall clock emitted files
tsc 7.1.0-dev.20260902.1 on on 33.39 s 1200
tsc 7.1.0-dev.20260902.1 off on 7.21 s 900
tsc 7.1.0-dev.20260902.1 off off 7.30 s 600
tsc6 6.0.3 on on 4.08 s 1200
tsc6 6.0.3 off on 3.70 s 900
tsc6 6.0.3 off off 3.62 s 600

Turning on sourceMap costs +26.2 s on the nightly and +0.4 s on 6.0.3. declarationMap is free on both implementations, within noise, so the whole difference comes from .js.map. Emitted file counts match between compilers for the same options, so neither is producing more output. Peak RSS was comparable.

The scaling itself is fine — the cost is linear in the amount of input on both implementations. What differs is the constant. On the nightly the sourceMap cost per file stays at roughly 90 ms across every size we tried:

files sourceMap on off delta per file
1 0.17 s 0.08 s 0.09 s 90 ms
10 1.19 s 0.29 s 0.90 s 90 ms
50 5.67 s 1.21 s 4.46 s 89 ms
300 33.71 s 7.09 s 26.62 s 89 ms

On 6.0.3 the same per-file cost is about 1.3 ms (0.38 s over 300 files), so Corsa spends roughly 70x more per file on the same source maps.

Each file holds exactly one 38 KB line. Since every file in the repro is the same size, we have not separated "per file" from "per byte of input" — both grow together here.

We originally hit this on a codebase of 1,209 comparable modules, where the same build went from 5 s to 88 s.

Narrowing down the trigger — one module from that codebase, variants with pieces removed, 300 copies of each, maps on, 7.0.2. Removing the type assertions, the large exported type declarations, or the cross-file import type each changed nothing (21-22 s, same as as-is). Removing the object literal value and keeping only the type declarations dropped it to 1 s.

The trigger is the large inline object literal value by itself.

🙂 Expected behavior

We expected the per-file cost of sourceMap to be in the same ballpark as 6.0.3 for the same input, rather than roughly 70x higher. Scaling is not the issue — both implementations are linear in input size, and it is the constant that is far apart.

Additional information about the issue

First, thanks for the work on the native port. On our own project, emit without source maps is faster on Corsa than on 6.0.3, which is why we moved our build over to it. This report is about the one case where that did not hold for us.

On the input shape: it is machine-generated rather than hand-written. @graphql-codegen/typed-document-node emits each GraphQL operation's AST as a single-line object literal.

export const UpdateItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateItem"}, /* … */ }]} as unknown as DocumentNode<>;

In the codebase mentioned above that is one 37,035-character line per file. A single line that long is unusual input, and we are not arguing it is reasonable — the generator only mimics it so the repro needs no GraphQL dependency.

We are reporting the difference between the two implementations on the same input rather than the absolute cost. It is an odd input, but if it got faster we could keep emitting with tsc alone instead of pulling a bundler into the pipeline just for this step, which would be a real help on our end.

Metadata

Metadata

Labels

BugA bug in TypeScript

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions