From main() to Response: The Complete Request Lifecycle in workerd
Prerequisites
- ›Article 1: Navigating the workerd Codebase
- ›Basic understanding of event-loop concurrency
- ›Familiarity with HTTP request/response semantics
From main() to Response: The Complete Request Lifecycle in workerd
In Part 1, we mapped out the directory structure and the six-layer architecture. Now we're going to walk through the code, tracing the exact path from the moment the workerd binary starts to the moment a JavaScript fetch() handler sends back a response. Along the way, we'll encounter a remarkably clever compiled-binary trick, a 6000-line "god file," and a threading model that bridges two fundamentally different memory worlds.
CLI Entry and Subcommands
The process begins in src/workerd/server/workerd.c++, which defines the CliMain class. This class uses KJ's MainBuilder pattern to register subcommands:
return kj::MainBuilder(context, getVersionString(),
"Runs the Workers JavaScript/Wasm runtime.")
.addSubCommand("serve", KJ_BIND_METHOD(*this, getServe), "run the server")
.addSubCommand("compile", KJ_BIND_METHOD(*this, getCompile),
"create a self-contained binary")
.addSubCommand("test", KJ_BIND_METHOD(*this, getTest), "run unit tests")
.addSubCommand("pyodide-lock", KJ_BIND_METHOD(*this, getPyodideLock),
"outputs the package lock file used by Pyodide")
.build();
The four subcommands serve distinct purposes: serve runs the server, test executes worker test handlers, pyodide-lock outputs Python dependency information, and compile does something unusual — it creates a self-contained binary.
The Compiled Binary Trick
One of the most inventive features in workerd is the ability to compile a worker into a standalone binary. The compile subcommand serializes the Cap'n Proto config (including all embedded source code) and appends it to the end of the workerd binary itself, followed by a magic suffix.
When the binary starts, the constructor checks whether it is such a compiled binary by reading the tail bytes of its own executable (lines 706-728):
if (kj::arrayPtr(magic) == kj::asBytes(COMPILED_MAGIC_SUFFIX)) {
// Oh! It appears we are running a compiled binary, it has a config appended.
// ... read the config from the end of the binary
config = capnp::readMessageUnchecked<config::Config>(
reinterpret_cast<const capnp::word*>(mapping.begin()));
}
If the magic bytes match, the binary skips the subcommand menu entirely and goes straight to serving. This means you can distribute a single executable that contains both the runtime and your application — no config files, no JavaScript files, just one binary.
flowchart TD
Start["workerd binary starts"] --> Check{"Magic bytes at end of file?"}
Check -->|Yes| Compiled["Read embedded config → serve"]
Check -->|No| SubCmd["Parse CLI subcommand"]
SubCmd --> Serve["workerd serve config.capnp"]
SubCmd --> Compile["workerd compile → append config to binary"]
SubCmd --> Test["workerd test"]
Serve --> ParseConfig["Parse Cap'n Proto config"]
Compiled --> Run["Server::run()"]
ParseConfig --> Run
Server::run() — The Bootstrap Sequence
Once the config is parsed, the serve subcommand calls Server::run(). This is the heart of the startup process:
kj::Promise<void> Server::run(
jsg::V8System& v8System, config::Config::Reader config,
kj::Promise<void> drainWhen) {
// 1. Configure logging from config
// 2. Build HTTP header table
kj::HttpHeaderTable::Builder headerTableBuilder;
globalContext = kj::heap<GlobalContext>(*this, v8System, headerTableBuilder);
// 3. Set up fatal error handling
auto [fatalPromise, fatalFulfiller] = kj::newPromiseAndFulfiller<void>();
// 4. Set up drain handling
auto forkedDrainWhen = handleDrain(kj::mv(drainWhen)).fork();
// 5. Construct all services (including compiling all workers)
co_await startServices(v8System, config, headerTableBuilder, forkedDrainWhen);
// 6. Bind all sockets
auto listenPromise = listenOnSockets(config, headerTableBuilder, forkedDrainWhen);
// 7. Finalize the header table
auto ownHeaderTable = headerTableBuilder.build();
// 8. Wait for either socket closure or fatal error
co_return co_await listenPromise.exclusiveJoin(kj::mv(fatalPromise));
}
The GlobalContext struct (defined at line 200) bundles the V8 system, the ThreadContext, and the HTTP header table — the shared infrastructure that all services need.
sequenceDiagram
participant CLI as CliMain
participant S as Server
participant GC as GlobalContext
participant Svc as Services
participant Sock as Sockets
CLI->>S: run(v8System, config, drainWhen)
S->>GC: Create GlobalContext (V8System + HeaderTable)
S->>Svc: startServices() — construct all Service objects
Svc-->>S: Services linked and ready
S->>Sock: listenOnSockets() — bind ports
Sock-->>S: Accepting connections
S->>S: co_await (listen ∥ fatalError)
Tip: Notice that
startServices()is awaited beforelistenOnSockets(). This means all workers are compiled and all service cross-references are resolved before any sockets start accepting connections. However, the header table is finalized after socket setup begins — headers can be registered lazily during service construction.
The Service Hierarchy
The Service base class is defined as a private class within server.c++. It defines the contract every service must fulfill:
class Server::Service: public IoChannelFactory::SubrequestChannel {
public:
virtual void link(Worker::ValidationErrorReporter& errorReporter) {}
virtual void unlink() {}
virtual kj::Own<WorkerInterface> startRequest(
IoChannelFactory::SubrequestMetadata metadata) override = 0;
virtual bool hasHandler(kj::StringPtr handlerName) = 0;
};
The link()/unlink() lifecycle is crucial: after all services are constructed, link() is called to resolve cross-service references (e.g., a service binding from one worker to another). Before destruction, unlink() breaks these cycles so kj::Own<T> pointers can be safely destroyed.
The concrete implementations declared at the bottom of server.h (lines 294-305):
classDiagram
class Service {
+link()
+unlink()
+startRequest() WorkerInterface
+hasHandler() bool
}
class WorkerService {
-LinkedIoChannels ioChannels
-Own~Worker~ worker
+startRequest()
}
class NetworkService {
+startRequest()
}
class ExternalHttpService {
+startRequest()
}
class ExternalTcpService {
+startRequest()
}
class DiskDirectoryService {
+startRequest()
}
Service <|-- WorkerService
Service <|-- NetworkService
Service <|-- ExternalHttpService
Service <|-- ExternalTcpService
Service <|-- DiskDirectoryService
WorkerService executes JavaScript. NetworkService provides outbound network access (it backs the implicit "internet" service). ExternalHttpService and ExternalTcpService forward to a specific remote address. DiskDirectoryService serves files from a directory.
WorkerService — The God Object
WorkerService is the most complex class in the entire codebase. Its declaration tells the story:
class Server::WorkerService final: public Service,
private kj::TaskSet::ErrorHandler,
private IoChannelFactory,
private TimerChannel,
private LimitEnforcer {
It implements five interfaces simultaneously. This is not accidental — it's a deliberate design choice. A WorkerService is the complete I/O environment for a single worker. It provides:
- Service: request dispatch
- IoChannelFactory: subrequest routing, actor namespace lookups, cache access
- TimerChannel: timer implementation (setTimeout, etc.)
- LimitEnforcer: CPU/memory limits
- TaskSet::ErrorHandler: background task error handling
This consolidation means that server.c++ is enormous (~6000 lines), but it also means that all I/O concerns for a worker live in one place. When you need to understand how a worker's subrequest is routed, you don't need to chase through multiple abstraction layers — it's all in WorkerService.
WorkerEntrypoint and Per-Request Dispatch
When an HTTP request arrives on a socket, the Server's HTTP listener calls Service::startRequest(), which returns a WorkerInterface. For WorkerService, this creates a WorkerEntrypoint.
The static factory method WorkerEntrypoint::construct() is where the per-request machinery is assembled:
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,
...);
Inside construct(), the init() method creates the IoContext — the per-request bridge that we'll explore deeply in Part 5. The IoContext binds a Worker instance to the current KJ event loop thread, providing the arena for all I/O objects this request will create.
sequenceDiagram
participant HTTP as HTTP Listener
participant WS as WorkerService
participant WE as WorkerEntrypoint
participant IoCtx as IoContext
participant Lock as Worker::Lock
participant JS as ServiceWorkerGlobalScope
HTTP->>WS: startRequest(metadata)
WS->>WE: construct(worker, ioChannelFactory, ...)
WE->>IoCtx: Create IoContext
Note over WE: IncomingRequest created
HTTP->>WE: request(method, url, headers, body)
WE->>Lock: takeAsyncLock()
Lock-->>WE: Lock acquired
WE->>JS: request(method, url, headers, body, response)
JS-->>WE: Response sent
WE->>IoCtx: drain() — wait for waitUntil() tasks
The request then flows to ServiceWorkerGlobalScope::request(), declared in global-scope.h. This method is the bridge between C++ and JavaScript — it invokes the worker's fetch() handler via JSG.
The drain/waitUntil Lifecycle
After the response is sent, the request isn't necessarily "done." Workers can call ctx.waitUntil(promise) to schedule background work that runs after the response. The IncomingRequest class manages this lifecycle:
delivered()— called when the request begins executing JavaScript- Response sent — the HTTP response body is transmitted to the client
drain()— waits for allwaitUntil()tasks to complete (with soft timeout)- Destruction — the IoContext and all its I/O objects are torn down
For actors (Durable Objects), the lifecycle is more complex: an IoContext persists across multiple incoming requests, and drain() only returns when either all tasks complete, a new request arrives, or the actor shuts down.
Tip: The
waitUntilTasksparameter in WorkerEntrypoint::construct() is akj::TaskSet&owned by the WorkerService, not by the request. This means background tasks can outlive the individual request's scope, which is exactly whatwaitUntil()needs.
Putting It All Together
Let's trace the complete path for our Hello World example from Part 1:
workerd serve config.capnp— CliMain parses the configServer::run()— creates GlobalContext, starts servicesstartServices()— constructs a WorkerService for "main", compilesworker.jsinto aWorker::Script, instantiates aWorkerlistenOnSockets()— binds*:8080, creates HTTP listener- Request arrives — listener calls
WorkerService::startRequest() WorkerEntrypoint::construct()— creates IoContextWorkerEntrypoint::request()— acquires async lock, enters V8ServiceWorkerGlobalScope::request()— invokesexport default { fetch() }via JSG- JavaScript runs:
return new Response("Hello World\n") - Response sent — HTTP response streamed to client
IncomingRequest::drain()— no waitUntil tasks, returns immediately- IoContext destroyed — request complete
This entire flow happens in a single thread on a single KJ event loop. There are no thread switches for a normal stateless request — the only async points are KJ promise resolutions, which are single-threaded cooperative multitasking.
What's Next
We've now seen the complete request lifecycle at the C++ orchestration level. But we glossed over the most interesting part: how does ServiceWorkerGlobalScope::request() actually call into JavaScript? How does a C++ kj::String become a JS string, or a C++ jsg::Ref<Response> become a JavaScript Response object? In Part 3, we dive into JSG — the macro-driven binding system that is workerd's most architecturally distinctive subsystem.