How Ladybird Browser Implements Web Worker Support: Process-Per-Worker Architecture

Ladybird Browser implements Web Worker support by launching each worker in a dedicated OS process, isolating JavaScript execution from the UI thread while maintaining standard DOM Worker APIs through a robust IPC layer.

Ladybird Browser provides comprehensive Web Worker support—including Dedicated, Shared, and Service Workers—by adhering to the HTML Living Standard's "run a worker" algorithm while enforcing strict process isolation. This architecture ensures that worker scripts execute in separate OS processes, preventing UI blocking and maintaining security boundaries between workers and the main browser context.

Process-Per-Worker Architecture in Ladybird

Ladybird’s Web Worker implementation follows a strict process-per-worker model. Unlike thread-based implementations that share memory space, Ladybird spawns a separate WebWorker executable for each worker instance. This design prevents memory corruption from affecting the main browser process and ensures that long-running scripts cannot freeze the UI.

Launching the WebWorker Process

When JavaScript code invokes new Worker('script.js'), the rendering engine triggers process creation through the helper process system. In [Libraries/LibWebView/HelperProcess.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Libraries/LibWebView/HelperProcess.cpp#L85), the launch_web_worker_process function constructs the command-line arguments and spawns the executable:

// Libraries/LibWebView/HelperProcess.cpp (lines 85-105)
ErrorOr<NonnullRefPtr<Web::HTML::WebWorkerClient>> launch_web_worker_process(Web::Bindings::AgentType type)
{
    Vector<ByteString> arguments;
    arguments.append("--type");
    switch (type) {
    case Web::Bindings::AgentType::DedicatedWorker: arguments.append("dedicated"); break;
    case Web::Bindings::AgentType::SharedWorker:    arguments.append("shared");    break;
    case Web::Bindings::AgentType::ServiceWorker:  arguments.append("service");  break;
    }
    // Socket plumbing establishes IPC connection to browser process
    return launch_server_process<Web::HTML::WebWorkerClient>("WebWorker"sv, move(arguments));
}

The function distinguishes between Dedicated, Shared, and Service worker types, passing the appropriate flag to the child process before establishing the IPC socket connection.

IPC Communication Between UI and Worker Processes

Once spawned, the worker process creates a ConnectionFromClient instance to handle IPC commands from the browser. The UI process holds a matching WebWorkerClient object that serves as the communication endpoint.

The IPC layer handles three critical operations:

  • start_worker – Receives the script URL, credentials, and environment snapshot to initialize execution
  • close_worker – Signals termination and triggers cleanup
  • Message port transfer – Serializes MessagePort objects for postMessage communication

The WorkerHost Lifecycle Implementation

At the heart of Ladybird’s Web Worker support is the WorkerHost class, which implements the HTML specification’s run a worker algorithm. This class manages the entire lifecycle from realm creation to script execution and termination.

Creating the JavaScript Realm and Global Scope

When ConnectionFromClient receives the start command, it instantiates WorkerHost and invokes WorkerHost::run. This method creates a fresh JavaScript realm and allocates the appropriate global scope object based on the worker type.

In [Services/WebWorker/WorkerHost.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Services/WebWorker/WorkerHost.cpp), lines 60-68 handle realm creation:

// Services/WebWorker/WorkerHost.cpp (excerpt)
void WorkerHost::run(GC::Ref<Web::Page> page, /* ... */)
{
    // Create a new JavaScript realm with the correct global object
    auto realm_execution_context = Web::Bindings::create_a_new_javascript_realm(
        Web::Bindings::main_thread_vm(),
        [&](JS::Realm& realm) -> JS::Object* {
            if (is_shared)
                return realm.heap().allocate<Web::HTML::SharedWorkerGlobalScope>(realm, page);
            return realm.heap().allocate<Web::HTML::DedicatedWorkerGlobalScope>(realm, page);
        },
        nullptr);
    
    // ... environment setup continues
}

The lambda function passed to create_a_new_javascript_realm determines whether to instantiate DedicatedWorkerGlobalScope or SharedWorkerGlobalScope, ensuring that the global self object exposes the correct API surface for each worker type.

Setting Up the EnvironmentSettingsObject

After realm creation, WorkerHost constructs a WorkerEnvironmentSettingsObject to maintain the worker's execution context. This object captures a snapshot of the creating document's origin, Content Security Policy (CSP), and other critical settings required by the Fetch algorithm.

The setup occurs in WorkerEnvironmentSettingsObject::setup, which initializes:

  • The realm execution context
  • The environment settings snapshot (origin, CSP, referrer policy)
  • The module map for importScripts support
  • The global object's bindings

Fetching the Initial Worker Script

With the environment established, WorkerHost initiates the script fetch using the HTML specification's perform the fetch hook. In [Services/WebWorker/WorkerHost.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Services/WebWorker/WorkerHost.cpp), lines 34-98 implement this logic:

// Services/WebWorker/WorkerHost.cpp (perform_fetch implementation)
auto perform_fetch = Web::HTML::create_perform_the_fetch_hook(
    inside_settings->heap(),
    [&](auto request, auto top_level, auto process_response) {
        // Use the standard Fetch algorithm
        auto fetch_controller = Web::Fetch::Fetching::fetch(
            inside_settings->realm(),
            request,
            Web::Fetch::Infrastructure::FetchAlgorithms::create(
                inside_settings->heap(),
                {
                    .process_response = move(process_response),
                    .process_response_end_of_body = [&](auto response) {
                        // Update worker URL and environment after fetch completes
                        inside_settings->set_url(response->url().value_or({}));
                    }
                }));
        
        // CSP checks and environment updates occur here
    });

This implementation ensures that worker scripts undergo the same security checks (CSP, CORS, referrer policy) as main-thread fetches, while maintaining the worker's isolated environment.

Worker Global Scopes and APIs

Once the script fetch completes and the realm is initialized, Ladybird exposes the standard Web Worker APIs through specialized global scope classes.

DedicatedWorkerGlobalScope vs SharedWorkerGlobalScope

Ladybird distinguishes between worker types through separate C++ classes that inherit from WorkerGlobalScope:

  • DedicatedWorkerGlobalScope – Created for new Worker() instances, exposing postMessage, close, and importScripts methods
  • SharedWorkerGlobalScope – Created for new SharedWorker() instances, additionally exposing the onconnect event and managing multiple connection ports

Both classes are instantiated in WorkerHost::run based on the is_shared boolean flag passed during process initialization.

Message Passing and Port Management

Communication between the main thread and workers relies on the HTML MessagePort API. When WorkerHost::run executes, it receives a serialized MessagePort from the UI process via the implicit_port argument.

The implementation in WorkerHost.cpp (lines 94-99) attaches this port to the global scope:

// Message port setup in WorkerHost::run
auto implicit_port = Web::HTML::TransferDataDecoder::decode_message_port(
    inside_settings->realm(), message_port_data);

// Expose the port to the worker's global object
global_scope->set_implicit_port(implicit_port);

This enables the standard worker.postMessage() and self.onmessage patterns to function across process boundaries, with IPC handling the underlying serialization and transfer semantics.

Key Source Files and Implementation Details

The Web Worker implementation spans multiple libraries and services within the Ladybird codebase:

Component Responsibility Key Source
Process launch helper Spawns WebWorker executable with type flags [Libraries/LibWebView/HelperProcess.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Libraries/LibWebView/HelperProcess.cpp#L85)
Worker service entry Main entry point for worker process [Services/WebWorker/main.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Services/WebWorker/main.cpp)
IPC connection (worker side) Receives start_worker and close_worker commands [Services/WebWorker/ConnectionFromClient.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Services/WebWorker/ConnectionFromClient.cpp)
Worker lifecycle Implements run a worker algorithm, realm creation, script fetch [Services/WebWorker/WorkerHost.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Services/WebWorker/WorkerHost.cpp)
Minimal page host Supplies Web::Page for fetch/timers inside worker [Services/WebWorker/PageHost.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Services/WebWorker/PageHost.cpp)
Browser-side IPC client UI process endpoint for worker requests Libraries/LibWeb/Worker/WebWorkerClient.{h,cpp}
Dedicated worker scope Global object for dedicated workers Libraries/LibWeb/HTML/DedicatedWorkerGlobalScope.{h,cpp}
Shared worker scope Global object for shared workers Libraries/LibWeb/HTML/SharedWorkerGlobalScope.{h,cpp}
Environment settings Worker-specific environment settings object Libraries/LibWeb/HTML/WorkerEnvironmentSettingsObject.{h,cpp}
Fetch integration Script fetching for workers [Libraries/LibWeb/HTML/Fetching.cpp](https://github.com/LadybirdBrowser/ladybird/blob/master/Libraries/LibWeb/HTML/Fetching.cpp)

Summary

Ladybird Browser’s Web Worker implementation delivers spec-compliant worker support through a process-per-worker architecture that prioritizes isolation and stability. The key architectural decisions include:

  • OS Process Isolation: Each worker runs in a separate WebWorker process spawned via HelperProcess::launch_web_worker_process, ensuring that worker scripts cannot crash or block the main browser UI.
  • Spec-Compliant Lifecycle: The WorkerHost class implements the HTML Living Standard's run a worker algorithm, handling realm creation, EnvironmentSettingsObject setup, and initial script fetching through the standard Fetch infrastructure.
  • Type-Specific Global Scopes: Ladybird distinguishes between DedicatedWorkerGlobalScope and SharedWorkerGlobalScope at allocation time, ensuring that each worker type exposes the correct JavaScript APIs (postMessage, importScripts, onconnect).
  • Robust IPC Layer: Communication between the UI process and worker processes flows through WebWorkerClient and ConnectionFromClient, with MessagePort objects handling the standard postMessage API across process boundaries.

Frequently Asked Questions

Does Ladybird use threads or processes for Web Workers?

Ladybird uses separate OS processes rather than threads for Web Workers. When JavaScript code creates a worker via new Worker(), the browser calls HelperProcess::launch_web_worker_process in Libraries/LibWebView/HelperProcess.cpp to spawn a new WebWorker executable. This process-per-worker model provides stronger isolation than thread-based approaches, ensuring that memory corruption or infinite loops in worker code cannot affect the main browser process or other workers.

How does Ladybird handle SharedWorker compared to DedicatedWorker?

Ladybird distinguishes between worker types during global scope allocation in WorkerHost::run. For Dedicated Workers, the code allocates DedicatedWorkerGlobalScope, while Shared Workers receive SharedWorkerGlobalScope. This occurs in Services/WebWorker/WorkerHost.cpp within the lambda passed to Bindings::create_a_new_javascript_realm. The is_shared boolean flag, passed during process initialization via the --type command-line argument, determines which global scope class is instantiated, ensuring that Shared Workers expose the onconnect event while Dedicated Workers use the standard postMessage API.

What security isolation does the process-per-worker model provide?

The process-per-worker architecture provides memory isolation and failure containment at the OS level. Since each worker runs in a separate address space via the WebWorker process, buffer overflows or use-after-free bugs in worker JavaScript cannot corrupt the main browser's heap. Additionally, if a worker enters an infinite loop or triggers an unrecoverable error, the browser process can terminate the specific worker process via ConnectionFromClient::close_worker without affecting other workers or the UI thread. This aligns with the web security model's requirement that workers execute in isolated contexts.

How does script fetching work in Ladybird's WebWorker implementation?

Script fetching follows the HTML Living Standard's perform the fetch hook implemented in WorkerHost::run. The code creates a fetch hook using Web::HTML::create_perform_the_fetch_hook, which invokes the standard Fetch algorithm (Web::Fetch::Fetching::fetch) to retrieve the worker script. This occurs in Services/WebWorker/WorkerHost.cpp around lines 34-98. The fetch operation respects the worker's EnvironmentSettingsObject, applying CSP policies, referrer policies, and CORS checks before executing the script. Once the fetch completes, the response body is evaluated within the worker's isolated JavaScript realm.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →