Server Boot and Request Lifecycle: From Socket to JavaScript
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:
-
Socket accepts connection — KJ's HTTP server parses the request and calls the service's
request()method. -
WorkerService creates WorkerEntrypoint — passing in the compiled Worker, the IoChannelFactory (which routes bindings), and limit/metrics objects.
-
IoContext is created — This request-scoped object at
src/workerd/io/io-context.h#L190bridges JS garbage collection with KJ event-loop I/O. It holds a reference to the Worker, manages object lifetimes viaIoOwn<T>, and tracks the request's metrics. -
Async lock acquired — Only one thread at a time can execute JavaScript in a given Worker's isolate. The
Worker::AsyncLockprovides fair queuing so requests are handled in order. -
JavaScript executes — Under the synchronous
Worker::Lock, the entrypoint callsServiceWorkerGlobalScope::request()atsrc/workerd/api/global-scope.h#L520, which dispatches to the user'sfetch()handler. -
Response streams back — The Response is converted to an HTTP response via KJ's HTTP API, and control returns to the socket layer.
-
waitUntil() tasks drain — Any deferred work registered via
ctx.waitUntil()continues executing after the response is sent, using thewaitUntilTaskstask set.
Tip: The
IoContext::IncomingRequestobject (see line 54 of io-context.h) is what enables multiple concurrent requests to share the same actor'sIoContextin Durable Objects, while stateless workers always get a freshIoContextper 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.