Read OSS

Server Boot and Request Lifecycle: From Socket to JavaScript

Advanced

Prerequisites

  • Article 1: Architecture Overview and Codebase Navigation
  • Familiarity with C++ virtual classes and factory patterns
  • Basic understanding of async I/O and event loops

Server Boot and Request Lifecycle: From Socket to JavaScript

In Article 1, we mapped out workerd's layered architecture and capability-based configuration model. Now we'll follow the code path that turns a workerd serve command into a running server, and then trace a single HTTP request from the moment it arrives on a socket all the way through to JavaScript execution and back.

This is where the architecture becomes concrete. We'll see how the Server class orchestrates startup, how the makeService() factory pattern creates different service types, and how WorkerEntrypoint bridges the gap between KJ's HTTP handling and V8's JavaScript execution.

The Boot Sequence: Server::run()

When workerd serve executes, the CLI layer parses the config and calls Server::run() at src/workerd/server/server.c++#L5609. This is the heart of the startup sequence:

kj::Promise<void> Server::run(
    jsg::V8System& v8System, config::Config::Reader config,
    kj::Promise<void> drainWhen) {
  // ...
  co_await startServices(v8System, config, headerTableBuilder, forkedDrainWhen);
  auto listenPromise = listenOnSockets(config, headerTableBuilder, forkedDrainWhen);
  auto ownHeaderTable = headerTableBuilder.build();
  co_return co_await listenPromise.exclusiveJoin(kj::mv(fatalPromise));
}
sequenceDiagram
    participant CLI as CliMain
    participant S as Server
    participant V8 as V8System
    participant Svc as Services
    participant Sock as Sockets

    CLI->>S: run(v8System, config)
    S->>S: Configure logging from config
    S->>S: Create GlobalContext
    S->>Svc: startServices()
    Svc->>Svc: Extract actor namespace configs
    Svc->>Svc: makeService() for each config entry
    Svc->>Svc: link() all services
    S->>Sock: listenOnSockets()
    Sock->>Sock: Bind addresses, start accepting
    S->>S: co_await listenPromise ∥ fatalPromise

The sequence is deliberately ordered. First, all services are created and linked together via startServices(). Then sockets are bound and begin accepting connections. The headerTableBuilder is finalized after service creation so that any HTTP headers registered by services are available to request parsing.

Notice the use of C++20 coroutines (co_await, co_return). workerd leans heavily on KJ's promise-based async I/O, and coroutines make the control flow readable despite being deeply asynchronous.

Service Creation: The makeService() Factory

The startServices() method at src/workerd/server/server.c++#L5732 iterates over every service in the config and calls makeService() for each one. The factory at src/workerd/server/server.c++#L4869 dispatches based on the service type:

kj::Promise<kj::Own<Server::Service>> Server::makeService(
    config::Service::Reader conf, ...) {
  switch (conf.which()) {
    case config::Service::WORKER:
      co_return co_await makeWorker(name, conf.getWorker(), extensions);
    case config::Service::NETWORK:
      co_return makeNetworkService(conf.getNetwork());
    case config::Service::EXTERNAL:
      co_return makeExternalService(name, conf.getExternal(), headerTableBuilder);
    case config::Service::DISK:
      co_return makeDiskDirectoryService(name, conf.getDisk(), headerTableBuilder);
  }
}
flowchart TD
    makeService["makeService(conf)"] --> Switch{"conf.which()"}
    Switch -->|WORKER| MW["makeWorker()\nCompile JS, create Worker + WorkerService"]
    Switch -->|NETWORK| MN["makeNetworkService()\nProxy to network addresses"]
    Switch -->|EXTERNAL| ME["makeExternalService()\nProxy to fixed remote server"]
    Switch -->|DISK| MD["makeDiskDirectoryService()\nServe files from local directory"]
    MW --> WS["WorkerService"]
    MN --> NS["NetworkService"]
    ME --> ES["ExternalService"]
    MD --> DS["DiskDirectoryService"]

Each factory method constructs a different Service implementation. The most complex by far is makeWorker(), which must parse modules, compile JavaScript via V8, resolve compatibility flags, and wire up bindings.

After all services are created, startServices() calls a linking phase. This is where ServiceDesignator references are resolved: each worker's bindings are connected to the actual service objects they reference. The lookupService() method at src/workerd/server/server.c++#L4902 performs the name lookup in the services map and reports configuration errors if a referenced service doesn't exist.

Tip: The two-phase create-then-link pattern is necessary because services can reference each other cyclically (worker A has a binding to worker B, and vice versa). Creating all services first, then linking, breaks the cycle.

WorkerService: The Multi-Interface Runtime Object

The WorkerService class at src/workerd/server/server.c++#L1882 is one of the most interesting design decisions in the codebase. It simultaneously implements four interfaces:

class Server::WorkerService final: public Service,
                                   private kj::TaskSet::ErrorHandler,
                                   private IoChannelFactory,
                                   private TimerChannel,
                                   private LimitEnforcer {
classDiagram
    class WorkerService {
        +request()
        +startRequest()
    }
    class Service {
        <<interface>>
        +request()
    }
    class IoChannelFactory {
        <<interface>>
        +getSubrequest()
        +getActor()
    }
    class TimerChannel {
        <<interface>>
        +now()
        +atTime()
    }
    class LimitEnforcer {
        <<interface>>
        +topUpActor()
        +getLimits()
    }

    WorkerService --|> Service
    WorkerService --|> IoChannelFactory
    WorkerService --|> TimerChannel
    WorkerService --|> LimitEnforcer

Why does one class implement four separate interfaces? In Cloudflare's production multi-tenant system, these are separate objects: the I/O channel factory routes through internal infrastructure, the timer channel implements Spectre mitigations, and the limit enforcer is backed by a billing system. For the single-tenant OSS runtime, there's no need for that separation—a single WorkerService owns its own timer, enforces its own limits (as no-ops or simple defaults), and routes its own I/O channels to other services in the same process.

This is a pragmatic design choice: it keeps the OSS runtime simple while preserving the same interface boundaries that the production system expects. The IoChannelFactory interface is particularly important—as we'll see in Article 4, it's the abstraction that connects worker bindings (like env.MY_SERVICE) to actual network destinations.

Request Dispatch: WorkerEntrypoint

When an HTTP request arrives on a socket, it eventually reaches a WorkerService, which constructs a WorkerEntrypoint to handle it. The WorkerEntrypoint::construct() method at src/workerd/io/worker-entrypoint.c++#L168 is the bridge between HTTP and JavaScript:

static kj::Own<WorkerInterface> construct(
    ThreadContext& threadContext,
    kj::Own<const Worker> worker,
    kj::Maybe<kj::StringPtr> entrypointName,
    Frankenvalue props,
    kj::Maybe<kj::Own<Worker::Actor>> actor,
    kj::Own<LimitEnforcer> limitEnforcer,
    kj::Own<void> ioContextDependency,
    kj::Own<IoChannelFactory> ioChannelFactory,
    kj::Own<RequestObserver> metrics,
    kj::TaskSet& waitUntilTasks,
    bool tunnelExceptions,
    kj::Maybe<kj::Own<BaseTracer>> workerTracer, ...);

The method's parameter list reveals the full scope of what's needed to handle a single request: the compiled Worker, the I/O channel factory, limit enforcement, metrics collection, tracing, and a task set for waitUntil() work.

The class comment at src/workerd/io/worker-entrypoint.c++#L28-L34 spells out its responsibilities:

Wrapper around a Worker that handles receiving a new event from the outside. In particular, this handles: Creating a IoContext and making it current. Executing the worker under lock. Catching exceptions and converting them to HTTP error responses. Or, falling back to proxying if passThroughOnException() was used. Finish waitUntil() tasks.

The WorkerEntrypoint class implements multiple WorkerInterface methods for different event types: request() for HTTP, runScheduled() for cron triggers, runAlarm() for Durable Object alarms, and customEvent() for extensible event types.

End-to-End Request Trace

Let's trace a complete request from socket to response:

sequenceDiagram
    participant Client
    participant Socket as HTTP Socket
    participant WS as WorkerService
    participant WE as WorkerEntrypoint
    participant IoC as IoContext
    participant Lock as Worker::Lock
    participant GS as ServiceWorkerGlobalScope
    participant JS as JavaScript Handler

    Client->>Socket: HTTP Request
    Socket->>WS: service.request()
    WS->>WE: construct(worker, ioChannelFactory, ...)
    WE->>IoC: new IoContext(thread, worker, actor, limitEnforcer)
    WE->>IoC: new IncomingRequest(ioContext)
    IoC->>Lock: worker.takeAsyncLock()
    Lock->>Lock: Wait for fair queue position
    Lock->>GS: globalScope.request(method, url, headers, ...)
    GS->>JS: handler.fetch(request, env, ctx)
    JS-->>GS: Response object
    GS-->>Lock: DeferredProxy<void>
    Lock-->>IoC: Release isolate lock
    IoC-->>WE: HTTP response written
    WE->>WE: Process waitUntil() tasks
    WE-->>Socket: Response complete
    Socket-->>Client: HTTP Response

Here are the critical steps in detail:

  1. Socket accepts connection — KJ's HTTP server parses the request and calls the service's request() method.

  2. WorkerService creates WorkerEntrypoint — passing in the compiled Worker, the IoChannelFactory (which routes bindings), and limit/metrics objects.

  3. IoContext is created — This request-scoped object at src/workerd/io/io-context.h#L190 bridges JS garbage collection with KJ event-loop I/O. It holds a reference to the Worker, manages object lifetimes via IoOwn<T>, and tracks the request's metrics.

  4. Async lock acquired — Only one thread at a time can execute JavaScript in a given Worker's isolate. The Worker::AsyncLock provides fair queuing so requests are handled in order.

  5. JavaScript executes — Under the synchronous Worker::Lock, the entrypoint calls ServiceWorkerGlobalScope::request() at src/workerd/api/global-scope.h#L520, which dispatches to the user's fetch() handler.

  6. Response streams back — The Response is converted to an HTTP response via KJ's HTTP API, and control returns to the socket layer.

  7. waitUntil() tasks drain — Any deferred work registered via ctx.waitUntil() continues executing after the response is sent, using the waitUntilTasks task set.

Tip: The IoContext::IncomingRequest object (see line 54 of io-context.h) is what enables multiple concurrent requests to share the same actor's IoContext in Durable Objects, while stateless workers always get a fresh IoContext per request.

What's Next

We've now traced the complete path from boot to request handling. In Article 3, we'll dive deep into the JSG binding layer—the macro system that makes it possible for C++ classes like Response, Request, and ReadableStream to appear as JavaScript objects. Understanding JSG is essential for anyone who wants to add new APIs to workerd or understand how the existing Web APIs are implemented.