Editor LSP That Stays Responsive in a Large Monorepo
Practical configuration for TypeScript LSP in a multi-package repo: project references, memory limits, and plugin discipline that keep completions fast.

You open a 200,000-line monorepo in your editor, type a single character, and wait. The autocomplete menu appears two seconds later, then freezes again on the next keystroke. This is not a hardware problem. It is a configuration problem in how your editor talks to the TypeScript language server.
Why LSP chokes on monorepos
The default TypeScript LSP client sends the entire workspace path to tsserver. When your workspace is a monorepo with fifty packages, tsserver responds by opening every tsconfig.json it can find. It indexes every source file, every node_modules directory, every .d.ts declaration—tens of thousands of files. On each keystroke, the language service re-checks the whole graph.
The root cause is the lack of a workspace root boundary. The editor tells tsserver the workspace is /home/you/monorepo, and tsserver assumes it needs to understand everything under that path. It does not. Most of the time, you are editing a single package and only need the type information for that package and its immediate dependencies.
To confirm this is happening in your setup, open the TypeScript output channel (or check vim.lsp.log in Neovim) and look for messages like Loading project '/home/you/monorepo/packages/frontend/tsconfig.json' followed by Loading project '/home/you/monorepo/packages/backend/tsconfig.json'. If you see more than two or three projects loading, you are paying for files you are not using.
Project references as the primary weapon
Project references are the standard TypeScript mechanism for splitting a codebase into composable units. They are not new, but most monorepo setups use them only for tsc --build and forget to tell the editor to respect them.
Enable composite projects
Every package in your monorepo needs composite: true in its tsconfig.json. This tells TypeScript that the project can be referenced by other projects and that it should produce .d.ts files for its output.
// packages/utils/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}The root tsconfig.json then lists references to each package:
// tsconfig.json (root)
{
"compilerOptions": {
"composite": true
},
"references": [
{ "path": "./packages/utils" },
{ "path": "./packages/core" },
{ "path": "./packages/frontend" }
]
}Disable referenced project load
This is the critical editor-side setting. By default, when you open a file in packages/frontend, the editor tells tsserver to load the frontend project and all projects it references. With disableReferencedProjectLoad: true, only the project containing the open file loads.
For VS Code, add to settings.json:
{
"typescript.tsserver.disableReferencedProjectLoad": true
}For Neovim with typescript-language-server:
require('lspconfig').typescript_server.setup({
settings = {
typescript = {
tsserver = {
disableReferencedProjectLoad = true,
},
},
},
})After this change, the initial file count in my 120-package monorepo dropped from ~50,000 to roughly 300. Completions return in under 50ms.
Memory and CPU limits you can set today
Even with project references, a single package can be large. tsserver defaults to 2 GB of heap. In a monorepo with packages that have heavy dependencies (anything depending on @types/react and @types/node together), that limit gets hit during full-project diagnostics.
maxTsServerMemory
VS Code exposes this directly:
{
"typescript.tsserver.maxTsServerMemory": 8192,
"typescript.tsserver.experimental.enableProjectDiagnostics": false
}Disabling enableProjectDiagnostics prevents tsserver from running the full project-wide diagnostic pass on every save. You still get file-level diagnostics from the current file's project.
For Neovim, pass the flag through the launch configuration:
require('lspconfig').typescript_server.setup({
cmd = {
"typescript-language-server",
"--stdio",
"--tsserver-max-memory=8192"
},
})maxOldSpaceSize
If you are running tsserver directly (not through the language server wrapper), set NODE_OPTIONS:
NODE_OPTIONS="--max-old-space-size=8192" npx tsserverThe 8 GB figure is a starting point. Profile with typescript.tsserver.log: verbose and look for FATAL ERROR: MarkCompactCollector entries. If you see those, bump to 12288.
Plugins that add latency and how to drop them
Every TypeScript plugin runs inside the same tsserver process. A plugin that takes 200ms to process completions blocks every completion request from every file in the project.
I audited a team's monorepo and found three plugins in tsconfig.json:
typescript-plugin-css-modules— resolves*.module.cssimports. Adds ~180ms per completion.ts-sql-plugin— provides autocomplete for SQL tagged templates. Adds ~250ms per completion.typescript-eslint-language-service— runs ESLint rules inside the editor. Adds ~300ms per diagnostic pass.
The fix is to move plugin functionality out of tsserver. For CSS modules, a separate d.ts file generator that runs on file save is faster and does not block the editor. For SQL, a snippet expander triggered by a keybinding (like ;sql expanding to a template literal) replaces the plugin with zero LSP overhead.
Remove plugins from tsconfig.json:
// Before
{
"compilerOptions": {
"plugins": [
{ "name": "typescript-plugin-css-modules" },
{ "name": "ts-sql-plugin" }
]
}
}
// After
{
"compilerOptions": {
"plugins": []
}
}For ESLint integration, use the editor's native ESLint extension instead of the TypeScript plugin. It runs as a separate process and does not block tsserver.
Workspace trust and exclusion patterns
Even with project references, tsserver watches directories listed in exclude if they are inside the workspace root. This is a known behaviour: the language server still tracks file system events for excluded directories to detect when they are created or deleted.
Add these to your root tsconfig.json:
{
"exclude": [
"node_modules",
"dist",
".cache",
".next",
"coverage",
"**/*.test.ts",
"**/*.spec.ts"
]
}For VS Code, there is an additional experimental flag that skips file patterns entirely:
{
"typescript.tsserver.experimental.excludeByFilePattern": [
"**/node_modules/**",
"**/dist/**",
"**/.cache/**"
]
}In Neovim, filter diagnostics to only show files in the current project root. This prevents the diagnostics panel from flooding with errors from unrelated packages:
vim.lsp.handlers["textDocument/publishDiagnostics"] = function(_, result, ctx)
if result.uri == nil then
return
end
local bufnr = vim.uri_to_bufnr(result.uri)
local bufname = vim.api.nvim_buf_get_name(bufnr)
local cwd = vim.fn.getcwd()
if not bufname:find(cwd, 1, true) == 1 then
return
end
vim.lsp.diagnostic.on_publish_diagnostics(_, result, ctx)
endComparing LSP clients: Neovim vs VS Code
VS Code uses a single tsserver instance per workspace. When you open a file in packages/frontend, that same process handles completions for files in packages/backend. The advantage is lower total memory usage. The disadvantage is that a slow completion in one package stalls the entire editor.
Neovim with typescript-language-server spawns a separate tsserver process per project root. If you open files from two different packages simultaneously, you get two processes. This uses more RAM but isolates latency—a stuck completion in one package does not affect the other.
| Feature | VS Code | Neovim + typescript-language-server |
|---|---|---|
| tsserver instances | 1 per workspace | 1 per project root |
| RAM per instance | ~400 MB baseline | ~400 MB baseline per root |
| Latency isolation | None | Full isolation |
| Inlay hints toggle | editor.inlayHints.enabled |
handlers['textDocument/inlayHint'] |
| Shared project cache | Yes | No |
Both editors support disabling inlay hints while typing, which recovers roughly 40ms per keystroke in large files. In VS Code:
{
"editor.inlayHints.enabled": "offUnlessPressed"
}In Neovim, clear the inlay hint handler during InsertEnter:
local group = vim.api.nvim_create_augroup("LspInlayHints", { clear = true })
vim.api.nvim_create_autocmd("InsertEnter", {
group = group,
callback = function()
vim.lsp.inlay_hint.enable(false)
end,
})
vim.api.nvim_create_autocmd("InsertLeave", {
group = group,
callback = function()
vim.lsp.inlay_hint.enable(true)
end,
})Failure modes and when project references aren't enough
Project references fail silently when there are circular dependencies. If packages/core references packages/utils, and packages/utils references packages/core, tsserver detects the cycle and reloads the entire reference graph. The reload triggers a full re-index of every project in the cycle.
Validate your reference graph with tsc --build --dry:
tsc --build --dry --listEmittedFilesIf the output shows projects being built more than once, you have a cycle. Break it by extracting the shared types into a separate packages/types project.
Path aliases (paths in tsconfig.json) that cross package boundaries also defeat project references. When @org/core maps to ../../packages/core/src, tsserver cannot resolve the reference statically and falls back to loading the entire workspace. Use paths only for local intra-package imports. For cross-package imports, use the package name directly and let Node module resolution handle it. For monorepos using pnpm, the workspace protocol and caching strategies described in pnpm Workspaces: Filters, Catalogs, and CI Caching can further reduce the number of files tsserver needs to index.
If the LSP still hangs after all these changes, enable verbose logging:
{
"typescript.tsserver.log": "verbose"
}Look for recurring Project 'X' loading entries. Each entry represents a full project load. If you see the same project loading every thirty seconds, something is triggering a reload—likely a file watcher event from a generated directory that is not properly excluded.
For monorepos that still struggle, consider running type checking as a separate build step rather than relying on the editor LSP. Tools like tsc --noEmit in CI, combined with a fast CLI startup using Fast TypeScript CLI startup with esbuild and lazy imports, give you the same type safety without the editor latency.
Key takeaways
- Enable
disableReferencedProjectLoad: truein your editor's TypeScript settings. This is the single highest-impact change for monorepo LSP responsiveness. - Set
maxTsServerMemoryto at least 8 GB and disableenableProjectDiagnosticsto prevent full-project diagnostic passes on every save. - Remove TypeScript plugins (
typescript-plugin-css-modules,ts-sql-plugin) fromtsconfig.json. Move their functionality to separate processes or snippet expanders. - Validate your project reference graph with
tsc --build --dryto catch circular dependencies that trigger full reloads. - Use the
excludearray andexcludeByFilePattern(VS Code) or diagnostic filtering (Neovim) to stoptsserverfrom watching generated and dependency directories.
Frequently asked questions
- Does disabling project diagnostics break type checking?
- No. It stops the editor from showing errors for files you haven't opened. The TypeScript compiler still catches everything during build. You only lose the red squiggles in closed files.
- Will project references force me to change my build setup?
- Only if you use `tsc` directly. Most bundlers (esbuild, webpack, Rollup) ignore references—they're purely for the editor and `tsc --build`. You can add references without touching your bundler config.
- Why does LSP still lag after I set memory limits and project references?
- Check for heavy plugins in `tsconfig.json`'s `plugins` array. Also verify the editor isn't indexing `node_modules` by mistake—run a file count on the workspace root. If it's over 10,000 files, tighten your `exclude` patterns.


