How IntelliSense, navigation and refactoring really work
Autocompletion, red squiggly lines and Rename Symbol feel like built in editor magic. Behind them runs a separate, long lived process: the TypeScript Language Server. Understanding how tsserver detects projects, answers requests and handles memory lets you fix performance problems deliberately instead of just restarting the editor and hoping.
Table of Contents
- 1. What the TypeScript Language Server is (and is not)
- 2. Architecture: tsserver, the Language Service and the project model
- 3. Editor communication: the tsserver protocol
- 4. IntelliSense in detail: completions, quick info and signature help
- 5. Navigation: Go to Definition, Find All References and Rename Symbol
- 6. Multi project handling: tsconfig.json and project references
- 7. Performance on large codebases: partial semantic mode and memory limits
- 8. Using the Language Service programmatically
- 9. tsserver, tsc and the Compiler API compared
- 10. Summary
- 11. FAQ
1. What the TypeScript Language Server is (and is not)
Anyone working with TypeScript uses the TypeScript Language Server practically every second at the editor, without ever consciously noticing the name. Every autocompletion, every red squiggly line under a type error, every quick info popup when hovering over a variable does not come from the tsc compiler itself, but from a separate process: tsserver. This process implements the TypeScript Language Server and runs continuously in the background, while tsc only starts on an explicit build invocation and then exits again.
Confusing tsc with the TypeScript Language Server often leads to wrong expectations. A developer running a type check on the command line with tsc --noEmit gets a complete but one time check of the whole project. The TypeScript Language Server instead works incrementally, keeps the state of the entire project in memory, and answers editor requests in milliseconds, because it does not recompile on every keystroke but only reanalyzes the affected parts of the program.
For Magento and Hyvä projects with TypeScript in the frontend build, this distinction is more than academic. Anyone who knows that the TypeScript Language Server is its own configurable process can deliberately set memory limits, enable logging and tackle performance problems in large monorepos instead of just restarting the editor and hoping for the best.
2. Architecture: tsserver, the Language Service and the project model
Internally the TypeScript Language Server consists of several layers. The bottom layer is the Language Service, a pure TypeScript module exported as ts.LanguageService, built on top of a Program, the source files and the type checker. The Language Service knows nothing about editors, sockets or a protocol. It only exposes functions such as getCompletionsAtPosition, getQuickInfoAtPosition or getDefinitionAtPosition, which take a file position and return a structured response.
Above that sits the actual tsserver process, which manages several Language Service instances, one per detected TypeScript project. A project is not necessarily defined via a tsconfig.json, there are also so called inferred projects for individual files without configuration. The TypeScript Language Server must therefore decide, whenever a file is opened, which project it belongs to before it can answer any request at all. This project detection is one of the most common sources of trouble in monorepos with multiple tsconfig.json files.
The third layer is the session object, which receives incoming requests from the editor, forwards them to the correct project, and serializes the response back. This separation into Language Service, project management and session is the reason the TypeScript Language Server can also be used programmatically outside an editor, more on that in section 8.
3. Editor communication: the tsserver protocol
Editors such as VS Code do not talk to tsc directly via the generic Language Server Protocol, but through an older, JSON based protocol of its own, native to the TypeScript Language Server and historically predating LSP. Every request is a JSON object with a seq number, a command name and an arguments object, sent over stdin to the tsserver process. The response comes back as a JSON line over stdout, correlated by the same seq number.
Other editors such as Neovim or Sublime Text instead usually access the same underlying TypeScript Language Server through an LSP bridge such as the community maintained package typescript-language-server. This bridge translates LSP requests into the native tsserver protocol and back, so editors with generic LSP client support get the same feature set as VS Code with its native integration.
// Request sent to tsserver via stdin (simplified, real payload has more fields)
{
"seq": 12,
"type": "request",
"command": "completions",
"arguments": {
"file": "/project/src/checkout.ts",
"line": 42,
"offset": 18,
"prefix": "getT"
}
}
// Response read from tsserver via stdout, correlated by "request_seq"
{
"seq": 0,
"type": "response",
"command": "completions",
"request_seq": 12,
"success": true,
"body": [
{ "name": "getTotal", "kind": "method", "sortText": "11" },
{ "name": "getTaxRate", "kind": "method", "sortText": "11" }
]
}
4. IntelliSense in detail: completions, quick info and signature help
The term IntelliSense bundles several independent functions of the TypeScript Language Server. Completions return, when a dot is typed after a variable, the list of possible properties and methods, derived from the inferred or declared type at exactly that code position. Quick info returns, when hovering over a symbol, its complete type signature including JSDoc comments, without the developer having to manually look up the declaration. Signature help shows, while typing a function call parenthesis, the expected parameters with their types and highlights which parameter is currently being entered.
Technically all three functions rely on the same mechanism. The TypeScript Language Server first resolves the type at the requested position through the type checker, then searches its symbol table for matching members and filters the result by visibility, that is whether a property is private, protected or public. For generic types the language server additionally resolves all type parameters in the current context before returning the completion list, which explains why completions for complex generic signatures can noticeably take longer than for simple object types.
import ts from "typescript";
// Minimal, self-contained demonstration of what tsserver does internally
function inspectAt(fileName: string, source: string, position: number) {
const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
const host: ts.LanguageServiceHost = {
getScriptFileNames: () => [fileName],
getScriptVersion: () => "1",
getScriptSnapshot: (name) =>
name === fileName ? ts.ScriptSnapshot.fromString(source) : undefined,
getCurrentDirectory: () => process.cwd(),
getCompilationSettings: () => ({ strict: true, target: ts.ScriptTarget.Latest }),
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
};
const languageService = ts.createLanguageService(host, ts.createDocumentRegistry());
// Same calls tsserver makes internally for hover and completions
const quickInfo = languageService.getQuickInfoAtPosition(fileName, position);
const completions = languageService.getCompletionsAtPosition(fileName, position, {});
console.log(quickInfo?.displayParts?.map((p) => p.text).join(""));
console.log(completions?.entries.map((e) => e.name).slice(0, 5));
}
5. Navigation: Go to Definition, Find All References and Rename Symbol
Go to Definition calls getDefinitionAtPosition and returns the exact file and line position where a symbol was declared, even if that declaration lives in a library's .d.ts file. The TypeScript Language Server distinguishes between the definition, that is where something was declared, and the implementation, that is where a concrete class actually contains code. This becomes relevant for interfaces with multiple implementations: Go to Implementation then lists every class that actually implements an interface.
Find All References does not search the source text for the name as a string, but uses the full type checker to find every actual usage of the same symbol, even across re exports, destructuring and different import alias names. This semantic precision is exactly what sets the TypeScript Language Server apart from a plain text search. A name property on a User object is never confused with a same named property on a Product object, because both symbols carry distinct identities resolved by the type checker.
Rename Symbol uses the exact same reference search, but instead of merely displaying it, replaces every location found in the source, including import statements and destructuring. A rename across dozens of files that completes correctly within seconds would hardly be safe without the semantic analysis of the TypeScript Language Server, because a plain search and replace operation would inevitably also hit coincidentally identical but unrelated identifiers.
6. Multi project handling: tsconfig.json and project references
In a monorepo with multiple packages the TypeScript Language Server has to decide which tsconfig.json is responsible for an opened file. The search walks upward through the folder hierarchy from the file's directory until a tsconfig.json is found whose include or files entries actually cover the file. If no matching configuration is found, the TypeScript Language Server creates an inferred project that only uses default options and typically offers noticeably weaker type checking than an explicitly configured project.
references in tsconfig.json enable project references. A main project points to several sub projects, each of which can be compiled independently with composite: true. The TypeScript Language Server uses these references so that navigating across package boundaries, for example from an app into a shared library, jumps directly to the source file instead of the generated .d.ts file, as long as disableSourceOfProjectReferenceRedirect is not enabled.
// Root tsconfig.json for a monorepo with two packages
{
"files": [],
"references": [
{ "path": "./packages/shared-types" },
{ "path": "./packages/storefront-app" }
]
}
// packages/shared-types/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
// packages/storefront-app/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"references": [{ "path": "../shared-types" }],
"include": ["src"]
}
7. Performance on large codebases: partial semantic mode and memory limits
On projects with tens of thousands of files, the TypeScript Language Server itself becomes the bottleneck. Every request for completions or diagnostics requires the type checker to resolve the relevant part of the dependency graph, and with deeply nested import chains that can noticeably cost time before a response reaches the editor. VS Code therefore automatically enables partial semantic mode on very large projects, where only the currently open file is analyzed semantically, while the rest of the project is initially checked only syntactically.
A second, often overlooked bottleneck is the Node heap size of the TypeScript Language Server process itself. By default Node.js limits the heap to a few hundred megabytes, which for large type declarations, for example generated GraphQL types or extensive Magento GraphQL schemas, can cause noticeable delays or even crashes. The typescript.tsserver.maxTsServerMemory setting in VS Code raises this limit specifically, without changing the system's global Node configuration.
# Enable verbose tsserver logging for troubleshooting (writes to a temp file)
export TSS_LOG="-level verbose -file /tmp/tsserver.log"
# Tail the log while reproducing a slow completion in the editor
tail -f /tmp/tsserver.log | grep -E "completions|Req [0-9]+ took"
# Find requests that took longer than 500ms
grep -E "took [5-9][0-9]{2}ms|took [0-9]{4,}ms" /tmp/tsserver.log
# VS Code setting to raise the tsserver heap limit (settings.json excerpt)
# "typescript.tsserver.maxTsServerMemory": 4096
8. Using the Language Service programmatically
Because the TypeScript Language Server builds on the publicly exported ts.LanguageService, the same feature set, meaning IntelliSense, diagnostics and navigation, can also be used outside an editor, for example for custom lint rules, documentation generators or CI checks that need to know more than plain syntax. This requires your own implementation of ts.LanguageServiceHost, an interface that tells the Language Service which files exist, what their current content is, and which compiler options apply.
The crucial difference from using tsserver as a child process: a programmatic Language Service instance runs in the same Node process as the calling tool, with no protocol overhead over stdin and stdout at all. This is suited for batch analyses, for example checking every file in a project for a particular API usage, while the interactive TypeScript Language Server stays optimized for editor integrations with persistent state and incremental updates.
import ts from "typescript";
import fs from "node:fs";
import path from "node:path";
// Batch-check every .ts file in a directory for semantic errors,
// without spawning a tsserver process or the tsc CLI
function checkProject(rootDir: string) {
const fileNames = fs
.readdirSync(rootDir)
.filter((f) => f.endsWith(".ts"))
.map((f) => path.join(rootDir, f));
const versions = new Map(fileNames.map((f) => [f, 1]));
const host: ts.LanguageServiceHost = {
getScriptFileNames: () => fileNames,
getScriptVersion: (f) => String(versions.get(f) ?? 0),
getScriptSnapshot: (f) =>
fs.existsSync(f) ? ts.ScriptSnapshot.fromString(fs.readFileSync(f, "utf8")) : undefined,
getCurrentDirectory: () => rootDir,
getCompilationSettings: () => ({ strict: true }),
getDefaultLibFileName: ts.getDefaultLibFilePath,
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
};
const service = ts.createLanguageService(host);
for (const file of fileNames) {
const diagnostics = service.getSemanticDiagnostics(file);
for (const d of diagnostics) {
console.log(`${file}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`);
}
}
}
9. tsserver, tsc and the Compiler API compared
Three ways of accessing the same underlying TypeScript logic are available, each with different strengths depending on the task. The following overview places tsc, the interactive TypeScript Language Server and direct use of the Language Service API according to typical use case.
| Tool | Typical use case | State between calls | Typical usage |
|---|---|---|---|
tsc --noEmit |
CI build, one time full check | No persistent state | Terminal, CI pipeline |
| TypeScript Language Server | Editor IntelliSense, live diagnostics | Persistent process per project | VS Code, editor plugins |
ts.LanguageService |
Custom tools, batch analyses | Inside calling process, freely managed | Custom scripts, linters |
ts.createProgram |
Transformers, code generation | Fresh per invocation | Build tools, codegen scripts |
LSP bridge (typescript-language-server) |
Editors without native tsserver integration | Translates LSP to tsserver protocol | Neovim, Sublime Text |
For day to day editor usage, the interactive TypeScript Language Server is irreplaceable, because only it keeps incremental state across keystrokes. For CI gates, tsc --noEmit remains the right choice, because a fresh, deterministic full check without state from previous runs is desired. Custom tools that need specialized analyses are best served by accessing the Language Service API directly, instead of remote controlling tsserver as a child process through its protocol.
Mironsoft
TypeScript tooling, IDE performance and Magento/Hyvä integrations
Slow IntelliSense in your TypeScript project?
We analyze project structure, tsconfig settings and tsserver logs, fix performance bottlenecks in the TypeScript Language Server and, if needed, build custom tools on the Language Service API for Magento and Hyvä projects.
tsserver diagnostics
Log analysis, memory limits and project structure checked for performance problems
Monorepo setup
Project references and tsconfig structure for fast, correct navigation
Custom tooling
Custom analysis scripts built on the Language Service API
10. Summary
The TypeScript Language Server is not a compiler feature, but a standalone process called tsserver, built on the publicly exported Language Service API, answering editor requests through its own JSON protocol. IntelliSense, Go to Definition, Find All References and Rename Symbol all rely on the same type checker, which resolves symbols semantically rather than textually, making these features considerably more reliable than a plain text search.
In monorepos, the project detection of the TypeScript Language Server determines speed and accuracy, while project references enable clean navigation across package boundaries. For performance problems, a raised memory limit via maxTsServerMemory and analysis of the tsserver log both help. Anyone needing custom analysis tools can access ts.LanguageService directly, without talking to tsserver as a child process at all, and gets the same accuracy as the editor, only usable programmatically.
TypeScript Language Server - The Essentials at a Glance
tsc vs. tsserver
tsc checks once and exits. The TypeScript Language Server runs continuously, keeps project state in memory and answers incrementally.
Semantic navigation
Go to Definition, Find All References and Rename use the type checker, not text search, so they stay reliable across re exports.
Performance tuning
Raise maxTsServerMemory, enable TSS_LOG for diagnostics, use project references for clean monorepo boundaries.
Programmatic access
ts.createLanguageService with a custom LanguageServiceHost enables custom tools without protocol overhead.