architecture
LSP routing strategies for monorepos
Language servers in monorepos face a tradeoff between isolated performance and cross-package awareness; routing strategies determine which you get.

Your monorepo has 40 services, each with its own TypeScript configuration. Jump-to-definition takes three seconds. Your editor's fan sounds like a data center. An AI agent makes a "small" change that breaks six downstream services because its language server only saw one package's context. You've got a routing problem.
The fundamental tension is between isolation and awareness. Route each package to its own language server and you get fast, focused responses—but no cross-package intelligence. Route everything to one massive server and you get complete context—but glacial performance and a single point of failure. Most teams discover this the hard way: after performance collapses, or worse, after a change that looked valid in the editor but broke in CI.
The single-instance baseline and where it breaks
Most editors default to spawning one language server per workspace root. For TypeScript, that means `tsserver` loads every `tsconfig.json` it discovers, builds a unified project graph, and attempts to track changes across all packages simultaneously.
This works until your repository crosses a few hundred thousand lines. Then you hit:
**Memory exhaustion**: A monorepo with 40 microservices and 15 shared libraries can push `tsserver` past 4GB RAM. The process starts swapping, then OOMs. On a 32GB laptop, you can run maybe four heavyweight TypeScript LSP servers before swapping kills productivity.
**Quadratic watch overhead**: File watchers scale poorly. Changing a widely-imported type in `packages/core/types.ts` triggers re-analysis of 30+ projects, each with its own incremental compilation state. When your CI system generates code or updates `package.json`, file watchers fire across all servers, triggering cascading invalidation storms.
**Stale diagnostics**: After the language server falls behind, you'll see red squiggles for code that compiled successfully five commits ago, or no errors for code that will fail CI. Developers learn to ignore the editor and trust only the test suite.
The symptom pattern: IntelliSense latency spikes when you edit files in heavily-depended-upon packages, and diagnostics lag 10-20 seconds behind your typing. Autocomplete becomes sluggish, go-to-definition times out, and developers wait 30 seconds for diagnostics after a single-character edit.
Per-package isolation: fast but limited
Partition the monorepo so each package gets its own LSP instance, routed by file path. Open `packages/api/src/handler.ts` and you get a `tsserver` instance that only knows about the `api` package. Jump to `@myorg/utils` and you're actually looking at the type definition file, not the source, because that package is external to this server's view.
For VS Code's multi-root workspaces:
{
"folders": [
{ "path": "packages/api" },
{ "path": "packages/core" },
{ "path": "packages/ui" }
],
"settings": {
"typescript.tsserver.maxTsServerMemory": 2048
}
}
Each folder spawns its own `tsserver`. The key constraint: `tsconfig.json` must use `references` to other packages, not direct `paths` mapping into their source. Otherwise, cross-package go-to-definition fails because the `api` server doesn't parse `core` source files.
This works well when packages genuinely are independent services that talk over HTTP. It breaks down when you have tight library coupling—imagine a design system where `@myorg/components` depends on `@myorg/tokens` and `@myorg/theme`. Every refactor requires manually updating three language servers or restarting the editor. Go-to-definition jumps to type declarations instead of implementation. Refactoring across package boundaries requires manually opening both workspaces.
For polyglot repos, this pattern extends naturally. Your Go services might share one `gopls` instance. Your Rust crates each need `rust-analyzer` because compile times benefit from isolated cargo workspaces. Python packages can share a Pyright server if you use a monorepo tool that handles editable installs correctly.
The heuristic: if opening a file should trigger a full dependency graph analysis (Python, JavaScript), use shared routing. If the language has explicit module boundaries and compile-time linking (Rust, Go), use isolated routing.
Project-wide routing with TypeScript project references
TypeScript 3+ introduced project references specifically for monorepos. Define a root `tsconfig.json` that references each package:
{
"files": [],
"references": [
{ "path": "./services/api" },
{ "path": "./services/worker" },
{ "path": "./packages/ui" }
]
}
Start one `tsserver` at the repo root. It loads all projects but maintains separate type-checking contexts. Rename a function in your utils library and every import across 40 services updates correctly.
The cost is memory and CPU. A 2M LOC TypeScript monorepo can consume 8-12GB just for the language server. Initial project load takes 30-90 seconds. And if that server crashes or hangs, your entire editor loses intelligence until restart.
The real bottleneck is often workspace indexing on startup—the initial project load when you open the repository. That's fixed by incremental build artifacts (`.tsbuildinfo`), not LSP routing. But once you're past startup, project-wide routing delivers the smoothest cross-package experience if you can afford the memory.
Dynamic server spawning with TTL
Spawn language server instances on-demand for the currently active file's package, and kill idle servers after a timeout. This reduces steady-state memory while still providing full IntelliSense when you're actively editing.
A custom LSP proxy sits between the editor and language servers:
const servers = new Map<string, LSPProcess>();
async function route(uri: string, method: string, params: any) {
const pkg = detectPackage(uri); // Parse nearest package.json
if (!servers.has(pkg)) {
servers.set(pkg, spawnServer(pkg));
}
servers.get(pkg).lastUsed = Date.now();
return servers.get(pkg).send(method, params);
}
setInterval(() => {
for (const [pkg, server] of servers) {
if (Date.now() - server.lastUsed > 5 * 60 * 1000) {
server.kill();
servers.delete(pkg);
}
}
}, 60_000);
This works for workflows that focus on one package at a time for extended periods. Backend engineers working on `packages/api` for a full day, then switching to `packages/queue` the next. The 5-minute TTL reclaims memory during lunch breaks and meetings.
The failure mode: switching between files in different packages causes 2-5 second cold-start pauses while the new server initializes and parses its project. The first autocomplete request after switching from `packages/api` to `packages/core` blocks until `tsserver` finishes its initial project load. The editor feels "chunky"—fast when you stay in one package, frozen when you jump between them.
Hybrid with shared hot-path caching
Run per-package servers for most code, but keep a single persistent instance for a designated "hot" package—usually shared types or utilities. Requests for files in `packages/core` always go to the long-lived server. Other packages get dynamic instances.
The routing logic prioritizes the hot path:
function route(uri: string, method: string, params: any) {
const pkg = detectPackage(uri);
if (pkg === 'packages/core') {
return sharedServer.send(method, params);
}
return getOrSpawnPackageServer(pkg).send(method, params);
}
The shared `core` package is imported by 80% of other packages. Keeping its server always-on means cross-package go-to-definition into core types is instant, even when the target package's server is cold.
For large TypeScript monorepos, a practical pattern emerges:
- Start **one LSP server per service** (`services/api`, `services/admin`, etc.)
- Configure each server to use project references to shared packages (`packages/common`, `packages/ui`)
- Use `tsconfig.json` path mapping so imports resolve correctly:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@company/common": ["../../packages/common/src"],
"@company/ui": ["../../packages/ui/src"]
}
},
"references": [
{ "path": "../../packages/common" },
{ "path": "../../packages/ui" }
]
}
Each service gets fast, isolated type-checking. Cross-package navigation works via references. Memory usage scales with the number of services you're actively editing, not the total package count.
The failure mode: developers over-centralize into the "core" package to avoid cold-start penalties. What started as 5 widely-used utilities becomes a 20k-line dumping ground. The single core server hits the same memory/latency issues as the original monolithic approach.
When naive routing causes production incidents
An AI coding agent at a client with 120 services was configured to use project-wide routing. During a refactor to move authentication middleware, the agent's language server saw the new location immediately and updated all imports. But the CI pipeline ran per-package TypeScript checks with isolated LSP contexts—the same pattern human developers used locally for performance.
The agent's PR passed its self-checks (compile, lint, test all green) because its wide-context LSP said the imports were valid. But CI failed in 18 packages that the agent never opened. The imports existed and technically resolved in the full-workspace tsconfig, but those packages' isolated LSP views couldn't find the moved module.
The fix required explicit dependency declarations in each affected package. The lesson: your agent's LSP routing must match the routing that gates production deploys.
At Goatfied, we enforce this by running validation steps in the same constrained LSP environment your CI uses, not just the agent's editing context. We run LSP routing in two phases. During the plan step, the agent gets project-wide context to understand dependencies and choose the right files to edit. But during the constrain and edit steps, we narrow to per-package routing—the same view your CI and local dev environment use.
This catches the class of errors where "it compiles in the agent's context but not in yours." The validate gate runs `tsc`, ESLint, and your test suite with the exact LSP configuration that production uses. If a change would break someone else's isolated view of the codebase, we catch it before proposing the diff. Because each edit is a small, reversible diff validated by compile/lint/test steps, we scope language server roots to the packages touched in the current plan.
You can self-host Goatfied and configure LSP routing per-team, per-repo, or per-package. The default is hybrid-with-boundaries because it matches how most engineering orgs actually structure their build systems.
Routing for incremental adoption
If you're moving from a monolith to monorepo, you'll hit an awkward middle state: hundreds of files still in a shared `src/` directory while new packages live under `packages/`. Per-package routing doesn't work because half your code isn't packaged yet. Project-wide routing is too slow.
The answer is staged namespacing. Create a pseudo-package for the legacy monolith with its own `tsconfig.json`:
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"exclude": ["../packages/**/*"],
"compilerOptions": {
"composite": true,
"outDir": "dist"
}
}
Route the monolith files to one LSP instance. New packages get their own. As you extract code, you update the monolith's exclude list and add explicit references to the extracted packages. The monolith's LSP shrinks over time while packaged code gets isolated intelligence.
This also lets you enforce boundaries: the monolith can import from packages, but packages cannot import from the monolith. Configure this at the language server level and auto-imports won't even suggest the legacy code.
Handling build-generated code
Many monorepos generate TypeScript definitions from Protobuf, GraphQL schemas, or OpenAPI specs. LSP servers need to index these files, but they only exist after running a build.
**Commit generated code**: Run `npm run codegen` and commit the output to `src/__generated__`. Simple, but pollutes diffs and requires discipline—forget to regenerate and you get stale types.
**Out-of-tree with symlinks**: Generate to `dist/` and symlink into `src/__generated__` as a dev-only step. Update `.gitignore` and `tsconfig.json`:
{
"compilerOptions": {
"paths": {
"@generated/*": ["./dist/generated/*"]
}
}
}
**On-demand generation hooks**: Configure your LSP client to run the generator before starting the server. For Neovim:
lspconfig.tsserver.setup({
on_new_config = function(config, root_dir)
vim.fn.system('cd ' .. root_dir .. ' && npm run codegen:types')
end,
})
Goatfied's constrain step prefers the symlink approach: our sandbox includes generated code in the readonly build cache, and our edit step never modifies it. This keeps the agent from accidentally suggesting changes to generated files that will get overwritten on the next build.
Operational checklist: preventing file-watcher storms
Before optimizing LSP routing, confirm that the language server is actually your problem:
1. **Measure end-to-end autocomplete latency**: Open a file, type a dot after an identifier, and time until suggestions appear. Repeat in different packages. If p99 is under 200ms, LSP routing isn't your bottleneck.
2. **Check server memory**: On macOS, `ps aux | grep tsserver`. If resident memory stays under 2GB across all instances, you have headroom.
3. **Watch file edit-to-diagnostic lag**: Change a type in a widely-imported file, save, and time until red squiggles update everywhere. If that's under 5 seconds, incremental compilation is working.
Regardless of routing strategy, prevent file-watcher amplification. When a build writes 50 generated files in `/proto/gen`, you don't want every language server receiving 50 `workspace/didChangeWatchedFiles` events.
Define explicit project roots in your editor config. For TypeScript:
{
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"typescript.tsserver.maxTsServerMemory": 4096,
"typescript.disableAutomaticTypeAcquisition": true,
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"packages/*/build": true
}
}
For Go monorepos, create a `gopls.yaml` at the root:
directoryFilters:
- "-**/node_modules"
- "-**/testdata"
- "-bazel-*"
build:
buildFlags: ["-tags=integration"]
env:
GOFLAGS: "-modcacherw"
The `directoryFilters` array tells `gopls` to skip directories before it ever stats their contents. Without this, `gopls` will index your entire `node_modules` tree looking for Go files that don't exist.
Runtime workspace switching without editor restarts
Restarting your editor to switch from `services/auth` to `services/billing` breaks flow. Instead, add a command to close the current workspace and open a new one without killing the editor process.
In VSCode, bind a keyboard shortcut to `workbench.action.openFolder`:
{
"key": "cmd+shift+o",
"command": "workbench.action.openFolder"
}
Hit the shortcut, pick a new folder, and VSCode will stop the current language servers, then start new ones for the new workspace. Total downtime: 2 seconds.
For Neovim, write a Lua function that stops active LSP clients and reruns the workspace detection logic:
vim.api.nvim_create_user_command('SwitchWorkspace', function()
vim.lsp.stop_client(vim.lsp.get_active_clients())
vim.cmd('cd ' .. vim.fn.input('New workspace root: ', vim.fn.getcwd(), 'dir'))
vim.cmd('edit')
end, {})
This kills all running language servers, changes the working directory, then reopens the buffer to trigger lazy-load logic.
Constrain language server scope in CI
In CI, scope each language server to the package or service under test. If your CI job runs tests for `packages/ui-components`, start `tsserver` with `rootPath` set to `packages/ui-components`, not the monorepo root. You'll cut language server startup time from 30 seconds to 3.
This matches Goatfied's validation approach: compile checks run in the minimal workspace, not the full monorepo. Faster feedback, less risk of cascading invalidation when unrelated packages change. When the agent moves to a new task, we tear down the previous LSP instances and spin up fresh ones for the new scope.
Related reading
- [Agentic planning for cross-package refactors](/blog/agentic-planning-for-cross-package-refactors)
- [Branch-aware context retrieval pipelines](/blog/branch-aware-context-retrieval-pipelines)
- [Completion ranking with repository signals](/blog/completion-ranking-with-repository-signals)
- [Context window budgeting under token pressure](/blog/context-window-budgeting-under-token-pressure)
