Read OSS

Rich Language Services: The Web Worker Architecture

Advanced

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:

  1. MonacoEnvironment.getWorker() — host provides a fully constructed Worker instance. Most flexible; used by advanced integrations.
  2. MonacoEnvironment.getWorkerUrl() — host provides a URL; Monaco constructs the Worker with type: 'module'.
  3. descriptor.createWorker — a built-in fallback that uses new 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.getWorkerUrl configuration. For Vite, you typically use the createWorker approach via ?worker imports.

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 reuse lspLanguageFeatures.ts for 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.