Rich Language Services: The Web Worker Architecture
Prerequisites
- ›Articles 1-2: Architecture overview and language definitions
- ›Strong understanding of Web Workers (postMessage, separate execution contexts)
- ›Familiarity with the Language Server Protocol concepts (completion, hover, diagnostics)
- ›Basic knowledge of the TypeScript compiler API
Rich Language Services: The Web Worker Architecture
Syntax highlighting via Monarch grammars is fast and runs on the main thread. But real IntelliSense — completions, diagnostics, hover information, formatting, go-to-definition — requires running a full language service. For TypeScript, that means running the TypeScript compiler. For CSS, it means the vscode-css-languageservice package. Running these on the main thread would freeze the editor.
Monaco solves this by running each language service in a dedicated web worker. This article dissects the worker architecture: how workers are created and resolved, the initialization protocol, the WorkerManager lifecycle pattern, and the critical design split between TypeScript's compiler-native adapters and the shared LSP-style adapter layer used by CSS, HTML, and JSON.
Worker Creation and MonacoEnvironment
When Monaco needs a worker, it calls through the getWorker() function in workers.ts. This function implements a resolution chain with three strategies:
src/internal/common/workers.ts#L55-L90
function getWorker(descriptor: {
label: string;
moduleId: string;
createWorker?: () => Worker;
}): Worker | Promise<Worker> {
const monacoEnvironment = (globalThis as any).MonacoEnvironment;
if (monacoEnvironment) {
if (typeof monacoEnvironment.getWorker === 'function') {
return monacoEnvironment.getWorker('workerMain.js', label);
}
if (typeof monacoEnvironment.getWorkerUrl === 'function') {
const workerUrl = monacoEnvironment.getWorkerUrl('workerMain.js', label);
return new Worker(
ttPolicy ? (ttPolicy.createScriptURL(workerUrl) as unknown as string) : workerUrl,
{ name: label, type: 'module' }
);
}
}
if (descriptor.createWorker) {
return descriptor.createWorker();
}
throw new Error(
`You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker`
);
}
flowchart TD
START[getWorker called] --> CHECK1{MonacoEnvironment<br/>exists?}
CHECK1 -->|No| CHECK3{descriptor<br/>createWorker?}
CHECK1 -->|Yes| CHECK2A{getWorker<br/>function?}
CHECK2A -->|Yes| RET1[Return Worker instance]
CHECK2A -->|No| CHECK2B{getWorkerUrl<br/>function?}
CHECK2B -->|Yes| RET2[Create Worker from URL<br/>with Trusted Types]
CHECK2B -->|No| CHECK3
CHECK3 -->|Yes| RET3[Call createWorker]
CHECK3 -->|No| ERR[Throw Error]
Three resolution strategies, in priority order:
MonacoEnvironment.getWorker()— host provides a fully constructedWorkerinstance. Most flexible; used by advanced integrations.MonacoEnvironment.getWorkerUrl()— host provides a URL; Monaco constructs the Worker withtype: 'module'.descriptor.createWorker— a built-in fallback that usesnew Worker(new URL(..., import.meta.url)). This is what the ESM build uses by default.
The Trusted Types integration (lines 1-53 of the same file) ensures CSP compliance. The ttPolicy wraps worker URLs through createScriptURL before passing them to the Worker constructor.
Tip: If you're integrating Monaco and seeing "You must define a function MonacoEnvironment.getWorkerUrl" errors, it means none of the three resolution strategies succeeded. The webpack plugin solves this by auto-generating the
MonacoEnvironment.getWorkerUrlconfiguration. For Vite, you typically use thecreateWorkerapproach via?workerimports.
The Two-Message Initialization Protocol
Once a Worker is created, Monaco uses a two-message handshake to initialize it. This happens in createWebWorker():
src/internal/common/workers.ts#L92-L111
export function createWebWorker<T extends object>(opts: IWebWorkerOptions): editor.MonacoWebWorker<T> {
const worker = Promise.resolve(
getWorker({
label: opts.label ?? 'monaco-editor-worker',
moduleId: opts.moduleId,
createWorker: opts.createWorker,
})
).then((w) => {
w.postMessage('ignore');
w.postMessage(opts.createData);
return w;
});
return editor.createWebWorker<T>({
worker,
host: opts.host,
keepIdleModels: opts.keepIdleModels
});
}
The first postMessage('ignore') is literally ignored — its purpose is to warm up the worker. When a web worker is created with type: 'module', the browser must fetch and evaluate the module before it can process messages. The first message triggers this evaluation.
On the worker side, the initialization protocol is handled by initialize.ts:
src/internal/common/initialize.ts#L1-L16
import * as worker from 'monaco-editor-core/esm/vs/editor/editor.worker.start';
let initialized = false;
export function initialize(callback: (ctx: any, createData: any) => any): void {
initialized = true;
self.onmessage = (m) => {
worker.start((ctx) => {
return callback(ctx, m.data);
});
};
}
And in the TypeScript worker entry point, the first message handler is set explicitly:
src/languages/features/typescript/ts.worker.ts#L12-L17
self.onmessage = () => {
// ignore the first message
initialize((ctx: worker.IWorkerContext, createData: ICreateData) => {
return create(ctx, createData);
});
};
sequenceDiagram
participant Main as Main Thread
participant W as Web Worker
Main->>W: postMessage('ignore')
Note over W: Module evaluates<br/>self.onmessage = () => { initialize(...) }
W->>W: First message received<br/>Replaces onmessage with initialize handler
Main->>W: postMessage(createData)
W->>W: initialize() calls worker.start()<br/>with factory function
W->>W: Factory creates TypeScriptWorker/CSSWorker
Note over W: Worker ready for RPC calls
The first onmessage handler consumes the 'ignore' message and replaces itself with the real handler. The second message carries createData — configuration like compiler options (for TypeScript) or CSS lint settings.
The WorkerManager Pattern
Each language feature service has a WorkerManager that handles worker lifecycle. The TypeScript and CSS implementations reveal an important design difference.
TypeScript WorkerManager — keeps the worker alive indefinitely:
src/languages/features/typescript/workerManager.ts#L11-L103
export class WorkerManager {
private _worker: editor.MonacoWebWorker<TypeScriptWorker> | null;
private _client: Promise<TypeScriptWorker> | null;
// ...
private _getClient(): Promise<TypeScriptWorker> {
if (!this._client) {
this._client = (async () => {
this._worker = createWebWorker<TypeScriptWorker>({
moduleId: 'vs/language/typescript/tsWorker',
createWorker: () => new Worker(
new URL('./ts.worker?esm', import.meta.url),
{ type: 'module' }
),
label: this._modeId,
keepIdleModels: true,
createData: {
compilerOptions: this._defaults.getCompilerOptions(),
extraLibs: this._defaults.getExtraLibs(),
// ...
}
});
// ...
})();
}
return this._client;
}
}
Note keepIdleModels: true — the TypeScript worker never releases idle models because the compiler maintains an internal program graph. Reconstructing that state is expensive.
CSS WorkerManager — uses a 2-minute idle timeout:
src/languages/features/css/workerManager.ts#L11-L53
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1000; // 2min
export class WorkerManager {
private _idleCheckInterval: number;
private _lastUsedTime: number;
constructor(defaults: LanguageServiceDefaults) {
this._idleCheckInterval = window.setInterval(() => this._checkIfIdle(), 30 * 1000);
this._lastUsedTime = 0;
// ...
}
private _checkIfIdle(): void {
if (!this._worker) { return; }
let timePassedSinceLastUsed = Date.now() - this._lastUsedTime;
if (timePassedSinceLastUsed > STOP_WHEN_IDLE_FOR) {
this._stopWorker();
}
}
}
The CSS worker is stateless — every operation re-parses the stylesheet from the mirror model. So the worker can be safely stopped after 2 minutes of inactivity and recreated on next use, freeing memory.
flowchart LR
subgraph TypeScript Worker
TSM[WorkerManager] --> TSW[Worker]
TSW --> TSC[TS Compiler<br/>LanguageService]
TSM -.->|keepIdleModels: true| TSW
TSM -.->|no timeout| TSW
end
subgraph CSS Worker
CSSM[WorkerManager] --> CSSW[Worker]
CSSW --> CSSS[vscode-css-<br/>languageservice]
CSSM -.->|2min idle timeout| CSSW
CSSM -.->|stateless| CSSW
end
Mode Setup and Provider Adapters
The tsMode.ts and cssMode.ts files are where the IntelliSense experience comes together. Each creates a WorkerManager and then registers adapter objects for every provider type.
TypeScript's mode setup:
src/languages/features/typescript/tsMode.ts#L43-L154
function setupMode(defaults: LanguageServiceDefaults, modeId: string) {
const client = new WorkerManager(modeId, defaults);
const worker = (...uris: Uri[]): Promise<TypeScriptWorker> => {
return client.getLanguageServiceWorker(...uris);
};
function registerProviders(): void {
if (modeConfiguration.completionItems) {
providers.push(
languages.registerCompletionItemProvider(
modeId, new languageFeatures.SuggestAdapter(worker)
)
);
}
if (modeConfiguration.hovers) {
providers.push(
languages.registerHoverProvider(
modeId, new languageFeatures.QuickInfoAdapter(worker)
)
);
}
// ... definitions, references, rename, formatting, diagnostics, etc.
}
}
Each adapter (like SuggestAdapter, QuickInfoAdapter) implements a Monaco provider interface and delegates to the worker proxy. When Monaco calls provideCompletionItems(), the adapter calls worker(uri) to get a proxy, then calls a method on the proxy which is transparently serialized and sent to the worker thread.
CSS's mode setup follows the same pattern but uses different adapter classes:
src/languages/features/css/cssMode.ts#L12-L125
export function setupMode(defaults: LanguageServiceDefaults): IDisposable {
const client = new WorkerManager(defaults);
const worker = (...uris: Uri[]): Promise<CSSWorker> => {
return client.getLanguageServiceWorker(...uris);
};
function registerProviders(): void {
if (modeConfiguration.completionItems) {
providers.push(
languages.registerCompletionItemProvider(
languageId, new languageFeatures.CompletionAdapter(worker, ['/', '-', ':'])
)
);
}
// ... hover, definitions, references, rename, colors, folding, etc.
}
}
Notice that CSS imports its adapters from ../common/lspLanguageFeatures while TypeScript imports from its own ./languageFeatures. This is the key architectural split we'll examine next.
Two Adapter Strategies: TypeScript vs LSP-Style
The four language services split into two camps:
TypeScript talks directly to the TypeScript compiler API. The worker wraps ts.createLanguageService():
src/languages/features/typescript/tsWorker.ts#L35-L40
export class TypeScriptWorker implements ts.LanguageServiceHost, ITypeScriptWorker {
private _ctx: worker.IWorkerContext;
private _extraLibs: IExtraLibs = Object.create(null);
private _languageService = ts.createLanguageService(this);
private _compilerOptions: ts.CompilerOptions;
The TypeScriptWorker class is the LanguageServiceHost — it implements the interface that the TypeScript compiler needs to resolve files, get script content, and determine compiler settings. The adapter layer on the main thread (in languageFeatures.ts) translates between Monaco API types and TypeScript compiler types.
CSS, HTML, and JSON wrap vscode-*-languageservice packages that use LSP types (from vscode-languageserver-types). The CSS worker demonstrates this:
src/languages/features/css/cssWorker.ts#L10-L50
export class CSSWorker {
private _languageService: cssService.LanguageService;
constructor(ctx: worker.IWorkerContext, createData: ICreateData) {
switch (this._languageId) {
case 'css':
this._languageService = cssService.getCSSLanguageService(lsOptions);
break;
case 'less':
this._languageService = cssService.getLESSLanguageService(lsOptions);
break;
case 'scss':
this._languageService = cssService.getSCSSLanguageService(lsOptions);
break;
}
}
}
Because CSS, HTML, and JSON all use LSP-style types, they share a common adapter layer:
src/languages/features/common/lspLanguageFeatures.ts#L6-L23
import * as lsTypes from 'vscode-languageserver-types';
export interface WorkerAccessor<T> {
(...more: Uri[]): Promise<T>;
}
export class DiagnosticsAdapter<T extends ILanguageWorkerWithDiagnostics> {
// Converts lsTypes.Diagnostic[] to Monaco markers
}
This file contains CompletionAdapter, HoverAdapter, DefinitionAdapter, and all other adapters that convert between Monaco API types and vscode-languageserver-types. It's ~400 lines of type conversion code shared by three languages.
flowchart TD
subgraph "Main Thread"
MA_TS[TypeScript Adapters<br/>languageFeatures.ts] --> |TS Compiler Types| PROXY_TS[Worker Proxy]
MA_CSS[LSP Adapters<br/>lspLanguageFeatures.ts] --> |LSP Types| PROXY_CSS[Worker Proxy]
end
subgraph "Worker Threads"
PROXY_TS --> TS_WORKER[TypeScriptWorker<br/>wraps ts.LanguageService]
PROXY_CSS --> CSS_WORKER[CSSWorker<br/>wraps vscode-css-languageservice]
PROXY_CSS --> HTML_WORKER[HTMLWorker<br/>wraps vscode-html-languageservice]
PROXY_CSS --> JSON_WORKER[JSONWorker<br/>wraps vscode-json-languageservice]
end
Tip: If you're building a custom language service for Monaco, the CSS/HTML/JSON pattern is the one to follow. Implement your service using
vscode-languageserver-types, create a worker that wraps it, and reuselspLanguageFeatures.tsfor the adapter layer. You'll get all the provider registrations for free.
What's Next
We've now seen how the runtime architecture works — from core, through grammars, to worker-based IntelliSense. In the next article, we'll shift to the build pipeline: how Rollup produces the tree-shakable ESM bundle, how Vite generates the legacy AMD build, and how the TypeScript compiler gets vendored and patched for browser use.