Skip to content

cd .. · ~/blog/eslint-svelte-extra-file-extensions.md

Why typed ESLint on Svelte files was 4× slower than it needed to be

updated 6 min read#svelte #eslint #typescript #tooling

:TOC 7 sections

Typed ESLint on the checkout app I work on took 28 seconds for 90 files. The cause was one extraFileExtensions: ['.svelte'] in the wrong config block, which made typescript-eslint rebuild the TypeScript project dozens of times per run. After I moved it, the lint took 6 seconds.

## Where the time went

28 seconds for 90 files is slow even with type-checked rules, so I turned on typescript-eslint’s debug output. It logs through debug:

terminal · sh
$ DEBUG='typescript-eslint:*' npx eslint .

Most of the output is noise. These lines kept repeating (timestamps and paths trimmed):

stderr · txt
typescript-eslint:typescript-estree:useProgramFromProjectService Updating extra file extensions: before=[ '.svelte' ]: after=[]
typescript-eslint:project-service:tsserver:info reload projects.
    reason: 'User requested reload projects: Creating possible configured project for …/feature-000/Feature.svelte to open'
typescript-eslint:project-service:tsserver:info After reloading projects..
typescript-eslint:typescript-estree:useProgramFromProjectService Extra file extensions updated: []

reload projects means TypeScript threw away every project and built it again. The line before it says why: extraFileExtensions went from ['.svelte'] to [], because a .ts file came right after a .svelte file. The next .svelte file flipped it back.

WARN:

The typescript-eslint docs suggest DEBUG='typescript-eslint:typescript-estree:*'. With typescript-eslint 8.71, that shows the “Updating extra file extensions” lines but not “reload projects”, because the tsserver logs moved to the typescript-eslint:project-service:* namespace. Use typescript-eslint:*.

## Why the value kept changing

With projectService, typescript-eslint gets each file’s program from a TypeScript project service, the same one your editor uses. TypeScript doesn’t know .svelte, so extraFileExtensions registers it.

Before each file, typescript-estree compares the file’s extraFileExtensions with the project service’s current value. If they differ, it calls setHostConfiguration, and TypeScript responds with reloadProjects(). The docs warn about it:

This causes the underlying TypeScript server to perform a full project reload.

My config set parserOptions in two blocks, as most Svelte configs do. The typed block covered **/*.ts and **/*.svelte and turned on projectService. A second block covered **/*.svelte, **/*.svelte.ts and **/*.svelte.js and set parser: ts.parser for svelte-eslint-parser. Only that second block had extraFileExtensions, so .svelte files got ['.svelte'] and .ts files got the default []. Components sit next to their .ts helpers, so the value flipped all through the run.

The config from npx sv create (sv 1.1.1) has the same split. It’s harmless until you turn on typed rules for .ts files. The eslint-plugin-svelte user guide gets it right and sets the option in a shared block, with the comment “Keep this consistent for TypeScript and Svelte files to avoid project reloads.” Vue and Astro configs can hit the same problem, since their files go through the same option.

## The fix

Give every block that sets parserOptions for typed files the same extraFileExtensions. My typed block already matched both **/*.ts and **/*.svelte, so I set the value there and removed it from the Svelte block:

eslint.config.js · diff
 	{
 		files: ['**/*.ts', '**/*.svelte'],
 		// ...
 		languageOptions: {
 			parserOptions: {
+				// typescript-eslint reloads the whole project when this value changes between files.
+				extraFileExtensions: ['.svelte'],
 				projectService: { allowDefaultProject: ['knip.ts'] },
 				tsconfigRootDir: import.meta.dirname
 			}
 		}
 	},
@@
 	{
 		files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
 		languageOptions: {
 			parserOptions: {
-				extraFileExtensions: ['.svelte'],
 				parser: ts.parser
 			}
 		}
 	},

Watch out for **/*.svelte.ts. Those files are TypeScript but also match the Svelte block, so in the old config they flipped the value too.

If several blocks need the value, share one const extraFileExtensions = ['.svelte']. Equal literals work too, since typescript-estree compares with util.isDeepStrictEqual, but the constant keeps them from drifting apart. Order counts: ['.svelte', '.vue'] and ['.vue', '.svelte'] are different values.

Keep the comment. Without it, the next person to tidy up the config will move the array back where it “belongs”.

## Results

I timed both configs with hyperfine. The old config stayed next to the new one as eslint.config.before.js, so both runs resolve import.meta.dirname and tsconfig.json the same way, and DEBUG was unset:

terminal · sh
$ hyperfine --warmup 2 --runs 10 -n before 'npx eslint -c eslint.config.before.js .' -n after 'npx eslint .'

The machine is an Apple M4 (10 cores, 24 GB) with macOS 27.0, Node 26.6.0 and hyperfine 2.0.0. The project is a single-package Svelte app with 24 .svelte files and 62 .ts files, 90 files linted in total, on eslint 10.13.0, typescript-eslint 8.71.2, eslint-plugin-svelte 3.23.1, svelte-eslint-parser 1.9.0, svelte 5.57.2 and TypeScript 6.0.3.

beforeafter
project reloads per run30–421
wall time, mean ± std dev (10 runs, 2 warmups)28.4 s ± 3.3 s6.1 s ± 0.5 s
fastest … slowest run23.7 s … 34.3 s5.6 s … 7.2 s
peak memory1.9 GiB1.4 GiB

That’s 4.6× faster on average. Before the fix, the reload count changed from run to run and the time changed with it. I didn’t dig into why the lint order varies. After the fix, both stayed the same.

## What didn’t help

I also measured two ESLint flags that look like they should help with a slow lint.

--concurrency auto picked 2 worker threads and took the broken config from 28.4 s to 14.8 s, still more than twice the 6.1 s the fixed config needs on one thread. With the fix in place it made no real difference (6.3 s over 5 runs) and raised peak memory from 1.4 GiB to 2.4 GiB. Worker threads don’t share modules, so I’d expect each one to load its own parser and TypeScript project. The threads split the reloads between them instead of removing them.

With a warm --cache and no changes, both configs finished in under a second (0.87 s and 0.65 s). That hides the problem locally, but CI usually starts cold, and every changed file is still linted the slow way. The cache also doesn’t track dependencies between files, so type-aware rules can report stale results. typescript-eslint’s FAQ is direct about it: “We don’t recommend using --cache.”

## Check your own project

Count the reloads instead of reading the logs:

terminal · sh
$ DEBUG='typescript-eslint:*' npx eslint . 2>&1 >/dev/null | grep -c 'info reload projects'

Keep 2>&1 before >/dev/null, or stderr never reaches grep. Match info reload projects because each reload also prints a reason: 'User requested reload projects: …' line. Pipe straight into grep instead of saving the logs: in a test project with 100 components, one run wrote 909 MB.

1 is healthy, since the first file sets the extensions once. 0 means nothing sets extraFileExtensions. A number that grows with the file count is this problem.

## Takeaway

If you use typed linting and non-.ts files go through the project service (Svelte, Vue, Astro):

  • Count the reloads once with the command above. One is fine. More than one is a config problem.
  • Use typescript-eslint:* for DEBUG, not the typescript-estree namespace from the docs.
  • Give every typed block the same extraFileExtensions, in the same order.
  • Check files that match more than one block, like *.svelte.ts.
  • Don’t treat --concurrency or --cache as a fix, because the reloads are still there.