architecture
Lsp Routing Strategies For Monorepos Operational Checklist: practical systems guide
Technical field guide on lsp routing strategies for monorepos operational checklist for teams building dependable AI coding workflows.
Monorepos collapse dozens of microservices into a single repository, but your language server doesn't know that. Point it at the root and watch it index 400,000 files, max out 32GB of RAM, then time out on every autocomplete. The fix isn't buying bigger machines—it's routing LSP requests so each workspace sees only what it needs.
This checklist walks through the operational patterns that keep language servers fast in monorepos: per-project LSP boundaries, lazy indexing strategies, and runtime toggling when engineers switch contexts. These aren't theoretical optimizations. They're the difference between 50ms and 8-second go-to-definition.
Map project boundaries before you start the server
Language servers assume they can walk upward from any file until they hit a language-specific root marker—`tsconfig.json`, `go.mod`, `Cargo.toml`. In a monorepo, that walk crosses service boundaries and triggers full-repo indexing.
Define explicit project roots in your editor config. For TypeScript monorepos using workspaces:
{
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"typescript.tsserver.maxTsServerMemory": 4096,
"typescript.disableAutomaticTypeAcquisition": true,
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"packages/*/build": true
}
}
The `maxTsServerMemory` cap forces the TypeScript server to stay within bounds. When it hits the limit, it sheds the least-recently-used projects instead of crawling everything. `disableAutomaticTypeAcquisition` stops the server from fetching types for every `@types/*` package it sees across all workspaces.
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.
Start one server per active project, not one per repo
Running a single language server for the entire monorepo sounds simple. In practice, you end up with a single process that's always indexing something, and every code edit in `services/auth` triggers recomputation in `services/billing`.
Spawn a language server per project and scope its working directory:
# .vscode/settings.json multi-root workspace
{
"folders": [
{ "path": "services/auth" },
{ "path": "services/billing" }
],
"settings": {
"gopls": {
"experimentalWorkspaceModule": true
}
}
}
Each folder gets its own `gopls` instance. The `experimentalWorkspaceModule` flag lets `gopls` see sibling modules for cross-project references without indexing the entire tree. When you open `services/auth/handler.go`, the LSP sees `go.mod` in `services/auth` and stops there. References to `services/billing` resolve through the module cache, not live indexing.
The tradeoff: cross-project refactors require opening both workspaces. But cross-project changes in monorepos are rare enough that manual coordination beats permanent 8GB RAM overhead.
Lazy-load workspaces on file open, not at startup
Editors that auto-discover all `tsconfig.json` files at startup will launch 40 language servers before you've opened a single file. Instead, defer language server startup until the engineer actually opens a file in that workspace.
In VSCode, set `typescript.tsserver.useSeparateSyntaxServer` to `false` and rely on the multi-root workspace config above. The TypeScript server won't start for `services/billing` until you open a `.ts` file inside it.
For Neovim with `lspconfig`, gate server startup behind a workspace detection function:
local function start_lsp_for_project(bufnr)
local root = vim.fs.dirname(
vim.fs.find({'go.mod', 'package.json'}, {
upward = true,
path = vim.api.nvim_buf_get_name(bufnr)
})[1]
)
if root then
vim.lsp.start({
name = 'gopls',
cmd = {'gopls'},
root_dir = root,
})
end
end
vim.api.nvim_create_autocmd('BufReadPost', {
pattern = {'*.go', '*.ts', '*.tsx'},
callback = function(args)
start_lsp_for_project(args.buf)
end
})
This autocmd searches upward for a project root only when the buffer loads, then starts a language server scoped to that root. Opening ten files in `services/auth` starts one server. Opening one file in `services/billing` starts a second.
Kill idle servers after 15 minutes
Language servers don't exit when you close all files in a workspace. They sit idle, holding indexes and memory, waiting for you to come back. After a few hours of switching contexts, you're running six zombie servers.
Implement an idle timeout. For `gopls`, use the `-listen.timeout` flag:
gopls serve -listen.timeout=15m
For TypeScript, VSCode doesn't expose a native timeout, but you can wrap `tsserver` in a process supervisor that kills after inactivity. Or accept that `tsserver` will idle indefinitely and rely on editor restarts to reclaim memory.
The 15-minute threshold is a starting point. Teams that switch contexts every 5 minutes should lower it. Teams that return to the same service after long meetings should raise it to 30 minutes.
Runtime toggling: switch workspaces without restarting the editor
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'))
-- Reopen current buffer to trigger autocmd
vim.cmd('edit')
end, {})
This kills all running language servers, changes the working directory, then reopens the buffer to trigger the lazy-load logic from earlier.
Constrain language server scope in CI and agents
Goatfied's agent loop runs `plan -> constrain -> edit -> validate -> retry` on every change. The **constrain** step matters here: we tell the language server to only index the files the agent is allowed to touch. In a monorepo, that might be a single service or a shared library, not the entire repo.
Set the `rootPath` or `workspaceFolders` parameter in the LSP initialization request to the narrowest path that satisfies the task:
// Agent sets workspace folder to services/auth
client.initialize({
workspaceFolders: [{
uri: 'file:///monorepo/services/auth',
name: 'auth'
}]
});
This keeps the agent's language server from indexing unrelated code. The agent gets autocomplete and diagnostics for `services/auth`, but doesn't wait for `services/ml-training` to finish indexing before it can validate a two-line change.
The same applies 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.
Related reading
- [Goatfied vs Cursor vs GitHub Copilot: a benchmark across 50 real PR tasks](/blog/goatfied-vs-cursor-vs-github-copilot-benchmark-50-pr-tasks)
- [The Goatfied agent loop: how we ship code that actually compiles first try](/blog/goatfied-agent-loop-compiles-first-try)
