Skip to content
FLAVIO COPES
flaviocopes.com

How workerd, the Cloudflare Workers runtime, is built

By

I read the workerd source to explain its server, service graph, V8 isolates, JavaScript APIs, request contexts, and self-hosting model.

~~~

Cloudflare Workers uses an open-source runtime named workerd.

workerd runs Worker code on the Cloudflare network. Wrangler also uses workerd for local development.

If Workers are new to you, start with your first Cloudflare Worker. My free Cloudflare Workers course takes you through a complete project.

You can run workerd on your server because Cloudflare publishes its source.

This post explains the path from a Hypertext Transfer Protocol (HTTP) request to JavaScript. It also explains the limits of self-hosting.

The first post explains how Plausible Analytics Community Edition is built.

For this post, I went through the workerd source and followed one request from the server to JavaScript and back.

workerd changes frequently. Some file names and implementation details can change after this post.

The project uses the Apache 2.0 license.

This analysis covers only the open-source runtime. Cloudflare does not publish all its deployment and security systems in this repository.

That difference is important for self-hosting.

Ten important design ideas

The workerd design gives us these important ideas:

The purpose of workerd

workerd is a server runtime for JavaScript and WebAssembly. It uses the V8 JavaScript engine.

The final d follows a Unix naming convention. It identifies a server daemon.

workerd is not a web framework. It is also not a general replacement for Node.js.

The runtime does these tasks:

A Worker can contain only this code:

export default {
  async fetch(request, env, ctx) {
    return new Response('Hello from workerd')
  },
}

The code does not call listen().

The runtime owns the server. The runtime calls the Worker when an event arrives.

This model differs from the usual Node.js server model.

The main request path

An HTTP request moves through these runtime layers:

flowchart LR
  accTitle: How an HTTP request moves through workerd
  accDescr: A configured socket sends a request to a Worker service. An I/O context runs the handler in a V8 isolate and returns a streamed response.
  Config["Cap'n Proto config"] --> Socket["KJ HTTP socket"]
  Config --> Service["Named service"]
  Config --> Bindings["Bindings and outbound services"]
  Client["HTTP client"] --> Socket
  Socket --> Service
  Service --> Entry["WorkerEntrypoint"]
  Entry --> Context["IoContext"]
  Context --> Isolate["V8 isolate lock"]
  Isolate --> Handler["JavaScript fetch handler"]
  Bindings --> Context
  Handler --> Response["Response stream"]
  Response --> Client

The diagram uses names from the source.

The server/ directory contains the binary and server setup. The io/ directory contains Worker and request-lifetime code.

The api/ directory contains JavaScript application programming interfaces (APIs). JavaScript Glue (JSG) connects C++ code to V8.

The main source directories

The repository contains a runtime, an API library, and a server:

workerd/
  docs/                  architecture and development documentation
  npm/                   packages for prebuilt workerd binaries
  samples/               runnable Worker configurations
  src/workerd/api/       web, Cloudflare, and Node-compatible APIs
  src/workerd/io/        isolates, requests, actors, I/O, and limits
  src/workerd/jsg/       C++ and JavaScript binding layer
  src/workerd/server/    CLI, config, sockets, and service orchestration
  src/workerd/util/      shared runtime utilities

Most of the source is C++.

V8 runs the JavaScript code. KJ supplies many asynchronous C++ components around V8.

KJ is part of the Cap’n Proto project. workerd uses KJ for promises, HTTP, streams, networking, and object ownership.

The repository also contains JavaScript, TypeScript, Rust, and Python.

Configuration defines the service graph

Most application servers start from application code. workerd starts from a Cap’n Proto configuration.

This example defines one Worker and one HTTP socket:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [
    (name = "main", worker = .mainWorker),
  ],
  sockets = [
    (
      name = "http",
      address = "*:8080",
      http = (),
      service = "main"
    ),
  ]
);

const mainWorker :Workerd.Worker = (
  modules = [
    (
      name = "worker.js",
      esModule = embed "worker.js"
    ),
  ],
  compatibilityDate = "2026-06-01"
);

This file defines these items:

  1. A Worker named main.
  2. An HTTP socket on port 8080.
  3. A route from the socket to the Worker.

The JavaScript code does not select the port. The configuration selects the port.

A service can have different implementations

The Service schema supports different destination types:

A socket gives an external client access to a service. A binding gives another Worker access to a service.

This model supports incoming requests, Worker calls, files, internal backends, and outbound network access.

Bindings give capabilities to a Worker

The bindings list controls the values in env.

A binding can contain text, JavaScript Object Notation (JSON), binary data, a key, a service, or a storage interface.

This example gives a Worker access to a service named api:

bindings = [
  (name = "API", service = "api"),
]

The Worker calls the service through the binding:

const response = await env.API.fetch('https://api/users')

The hostname does not select the destination. The binding selects the destination.

The api service can be another Worker in the same process. This call does not require a network socket.

Cloudflare calls this type of service a nanoservice.

Global fetch() also uses a capability

workerd creates an internet service by default. This service supports the global fetch() function.

The default network policy permits public addresses. It blocks private addresses.

This policy helps to prevent server-side request forgery (SSRF).

User input cannot make the default service contact a private backend. The operator can define an explicit service binding for that backend.

The operator can change the default policy. The configuration makes this security boundary visible.

The workerd start sequence

The command-line entry point is in workerd.c++.

The serve command loads the configuration. It then initializes V8, creates a Server, and calls Server::run().

The Server::run() method has two main phases:

startServices()
listenOnSockets()

workerd builds the internal service graph before it accepts traffic.

The first phase creates Workers, networks, external servers, disk services, bindings, actor namespaces, and tail workers. It also validates service references.

The second phase opens the configured sockets. Each socket sends its traffic to a selected service.

One process contains one V8 system

The JSG documentation describes a process-wide V8System.

workerd creates this system one time. Individual Worker isolates live in the system.

Each isolate has a separate JavaScript heap. An isolate cannot directly share JavaScript objects with another isolate.

This design uses fewer resources than one operating-system process for each Worker.

JSG gives native APIs to JavaScript

V8 implements JavaScript. It does not implement Cloudflare runtime APIs.

For example, V8 does not supply KVNamespace, R2Bucket, DurableObjectState, or ExecutionContext.

workerd implements these APIs in C++. JavaScript Glue (JSG) exposes the APIs to JavaScript.

The WorkerdApi declares the types that an isolate can use.

The API contains these groups:

JSG converts values between C++ and JavaScript. It can expose a C++ resource or method as a JavaScript object or method.

A native stream can become a JavaScript ReadableStream.

Native implementations also reduce duplicate code. workerd does not load a separate JavaScript implementation of each API into every isolate.

Compatibility dates select runtime behavior

Each Worker configuration contains a compatibility date:

compatibilityDate = "2026-06-01"

This value controls runtime behavior. It is not only deployment metadata.

The compileCompatibilityFlags() function reads the date and optional flags.

The function produces the behavior flags for one Worker:

compatibility date
      +
explicit enable and disable flags
      |
      v
compiled runtime behavior flags
      |
      v
API surface and semantics for this isolate

The runtime rejects a date that is newer than the runtime. It also rejects a date in the future.

A new workerd version can still supply old behavior to an older Worker.

This system lets Cloudflare update V8 without changing all applications on the same date.

A workerd version number also includes its release date and a revision number.

workerd prepares the Worker script

The main script code is in worker.c++.

A Worker::Isolate owns the V8 isolate and runtime API. A Worker::Script owns the compiled application in that isolate.

workerd takes the isolate lock and creates a V8 context during script construction.

For an ECMAScript (ES) module Worker, workerd loads modules and resolves imports. It compiles the main module and finds the exported handlers.

For Service Worker syntax, workerd compiles one global script. The runtime later sends events to the registered handlers.

The ES module syntax is the current model:

export default {
  async fetch(request, env, ctx) {
    return new Response('Hello')
  },
}

workerd prepares the script before it receives a normal request.

The path of one HTTP request

Assume that a client sends this request:

curl http://localhost:8080/hello

The request first reaches the configured http socket.

KJ accepts the request

The HTTP server code is in server.c++.

The connection implements the KJ HttpService interface.

Its request() method receives these values:

The server can change request metadata and headers when the configuration requires a change.

The server then asks the selected service to start a request.

The service creates a Worker entrypoint

For a Worker service, WorkerService::startRequest() creates a WorkerEntrypoint.

This object connects the generic HTTP server to one Worker export.

It also creates a request observer. It creates configured tail workers for logs and traces.

JavaScript has not run at this point. The server still has native HTTP values.

Each request gets one IoContext

WorkerEntrypoint::init() creates an IoContext for a stateless request.

The IoContext contains the state for the active request:

The context creates an important ownership boundary.

An input/output object from one request must not operate in a different request. workerd connects each native resource to its IoContext.

The runtime reports an error if code uses a resource outside its owning context.

The request waits for the isolate lock

One V8 isolate cannot run JavaScript on two threads at the same time.

WorkerEntrypoint::request() calls context.run().

The context waits for a fair asynchronous lock. It then takes the V8 isolate lock and enters the correct JavaScript context.

workerd can now operate on JavaScript objects.

KJ promises and JSG connect asynchronous native operations to JavaScript promises. A continuation returns to the correct context after an operation finishes.

Native HTTP becomes web-standard JavaScript

The entrypoint calls ServiceWorkerGlobalScope::request().

This method converts native HTTP data to JavaScript data.

It creates JavaScript Headers. It also creates a Request with the method, URL, metadata, abort signal, and body.

workerd does not automatically put the complete request body into memory.

The runtime wraps the native input stream in a JavaScript ReadableStream.

This Worker returns the input stream as the response body:

export default {
  async fetch(request) {
    return new Response(request.body)
  },
}

The runtime does not need one large JavaScript string or ArrayBuffer for the complete body.

workerd calls the exported handler

For an ES module Worker, the runtime calls the exported fetch handler with three values:

request, env, ctx

request contains the JavaScript request data. env contains configured bindings.

ctx contains request controls. These controls include waitUntil() and passThroughOnException().

The handler can return a Response or a Promise<Response>.

The response returns to C++

workerd verifies that the result is a valid Response.

The runtime writes the status, headers, and body to the KJ response interface.

A JavaScript ReadableStream can continue to produce data after the handler returns the Response object.

The native HTTP server sends each data part to the client.

A client disconnect causes an abort

The runtime monitors the client connection.

workerd can activate the request AbortSignal if the client disconnects before the response finishes.

The runtime also prevents use of an expired native request stream. The source calls this operation neutering.

JavaScript sees a simple Request object. A native socket with a shorter life exists below that object.

The function of waitUntil()

A Worker can return a response and continue a separate task:

export default {
  async fetch(request, env, ctx) {
    ctx.waitUntil(env.LOGS.fetch('https://logs/write'))

    return new Response('ok')
  },
}

The ExecutionContext::waitUntil() implementation adds the promise to the current IoContext.

After the response finishes, WorkerEntrypoint drains the remaining tasks for the request.

The response does not wait for the logging request. The request context waits for it.

waitUntil() does not provide a durable job queue. A process failure can remove an unfinished task.

Use Cloudflare Queues when the work must survive failures and retry safely.

Subrequests use configured channels

Global fetch() and binding fetch() calls do not create a Node.js socket directly.

Each call asks the current IoContext for an outbound channel. A channel number identifies the configured destination.

Global fetch() usually uses the internet service. env.API.fetch() uses the service connected to the API binding.

The destination can be one of these service types:

The JavaScript interface does not change. The configuration selects the implementation.

This design also supports tests. A test configuration can replace a production backend with a small mock Worker.

Same-process services remove a network operation

Microservices give independent service boundaries. Network calls also add serialization, routing, authentication, retries, timeouts, and failure conditions.

workerd can run several Workers in one process. Service bindings connect these Workers.

The caller uses a service interface. workerd can deliver the request in the same process and thread.

The original workerd announcement calls this design a nanoservice model.

Too many service boundaries can still make an application difficult to understand. workerd only reduces the runtime cost of each boundary.

workerd and Node.js have different host models

Node.js usually gives JavaScript broad access to the operating system.

A Node.js application can open a port, read files, start child processes, and load native modules.

workerd uses the opposite model. The host creates the server and gives selected capabilities to the Worker.

The default API uses web standards:

workerd also implements many Node.js compatibility APIs. The nodejs_compat flag adds compatible APIs and modules.

This flag does not create a normal Node.js process.

Some packages require unrestricted files, native addons, child processes, or a global server. These packages can fail or have different behavior.

The workerd programming model remains event-based and capability-based.

Wrangler uses workerd locally

If you have not used the tool yet, start with my Wrangler guide.

This command starts a local Workers environment:

npx wrangler dev

Miniflare prepares the environment. workerd runs the Worker code.

Miniflare supplies local implementations for services such as D1, KV, R2, Queues, Durable Objects, and the Cache API.

The local architecture has this sequence:

Wrangler
  -> Miniflare builds the local service graph
  -> workerd executes Worker code
  -> local services implement development bindings

workerd supplies the runtime and binding interfaces. Miniflare supplies much of the local development platform.

I use this path when I run Cloudflare D1 locally.

This design gives good runtime parity. It does not copy the complete Cloudflare platform to a local computer.

Direct operation of workerd

Wrangler is convenient, but workerd does not require Wrangler.

Create a project and install workerd:

mkdir workerd-demo
cd workerd-demo
npm init -y
npm install --save-dev workerd

Create worker.js with this content:

export default {
  async fetch(request) {
    const url = new URL(request.url)

    return Response.json({
      message: 'Hello from workerd',
      path: url.pathname,
    })
  },
}

Use the config.capnp file from the earlier example. Then start the server:

npx workerd serve config.capnp

Send a request from a different terminal:

curl http://localhost:8080/hello

The server returns this response:

{"message":"Hello from workerd","path":"/hello"}

This example does not require a Cloudflare account. It does not deploy code to the Cloudflare network.

A self-contained executable

The serve command reads configuration and source files during startup.

The compile command puts those files in one executable:

npx workerd compile config.capnp > workerd-demo
chmod +x workerd-demo

The output contains these components:

Run the executable:

./workerd-demo

The application no longer needs worker.js, config.capnp, Node.js, or node_modules during operation.

The executable depends on its operating system and CPU architecture. Build an executable for each deployment target.

Production self-hosting

You can self-host workerd in production.

The workerd README describes workerd as an application server for self-hosted Worker applications.

The repository includes a production example with systemd socket activation.

In that example, systemd owns ports 80 and 443. It starts workerd as an unprivileged user and restarts a failed process.

You can also run workerd in a virtual machine or a container system.

Use an exact workerd version in production. Do not download an unspecified current version during each start.

A production system also needs these components:

workerd is the application server in this system. The operator supplies the platform functions.

On the managed platform, Workers observability gives you logs and traces. A self-hosted setup needs its own equivalent.

Storage in a self-hosted system

The workerd configuration supports interfaces for KV, R2, Queues, Analytics Engine, Hyperdrive, and other Cloudflare services.

The standalone binary does not contain the managed Cloudflare storage systems.

Many bindings send API operations to a configured service. The operator must supply a service that implements the required protocol.

Durable Objects have different local storage options.

workerd can run local Durable Objects with memory storage. It also has an experimental disk mode that uses SQLite files.

My Durable Objects guide explains how the managed version handles state and storage.

The configuration source states that each object stays local to one runtime instance.

workerd does not distribute these objects across a cluster.

Memory storage loses data when the process stops. Disk storage survives a restart, but the mode is experimental and instance-local.

Miniflare provides more development implementations. One local process still does not provide Cloudflare distributed storage.

The standalone server does not apply Cloudflare limits

The runtime defines limit interfaces in limit-enforcer.h.

These interfaces can control the following resources:

The Cloudflare platform can connect its limit systems to these interfaces.

The standalone server uses NullIsolateLimitEnforcer. Its per-request enforcer also does not apply limits.

The standalone runtime does not know the machine size, tenant model, billing policy, or orchestration system.

Do not assume that self-hosted workerd applies Cloudflare CPU, memory, or subrequest limits.

Use operating-system controls, virtual machines, containers, or another supervisor to limit the process.

workerd is not a complete hostile-code sandbox

V8 isolates separate JavaScript heaps. This separation is not sufficient for arbitrary hostile code.

The workerd README gives this warning. The repository does not include all Cloudflare security layers.

A defect in V8, workerd, the operating system, or the CPU can cross an isolate boundary.

Run untrusted user code inside a stronger sandbox. A correctly configured virtual machine can provide one part of that system.

Do not assume that a normal container is a complete security boundary for hostile code.

This warning does not have the same effect on trusted application code.

Running your own Worker is a normal server deployment. Running untrusted user programs requires a security platform.

workerd is not the complete Cloudflare platform

workerd is an important component of Cloudflare Workers. It is not the complete product.

One self-hosted workerd server does not supply these Cloudflare functions:

The following table shows the difference:

Open-source workerdManaged Cloudflare Workers
JavaScript and WebAssembly runtimeRuntime and global hosting platform
V8 isolates and Worker APIsAdditional multi-tenant security layers
Configured sockets and servicesGlobal traffic routing
Local process life cycleDeployment and orchestration control plane
Binding interfacesManaged storage implementations
Standalone limit hooks without enforcementCloudflare resource policies and metering
Infrastructure operated by youInfrastructure operated by Cloudflare

The open-source runtime gives application-code portability. It does not give automatic platform portability.

A Worker can depend on D1, R2, Queues, Durable Objects, or Artificial Intelligence (AI) services.

For that Worker, code migration is only one task. Data, service, and operations migrations are also necessary.

These platform services form the main lock-in boundary.

Failure and behavior boundaries

An architecture analysis must include failure conditions.

waitUntil() tasks are not durable

The runtime keeps each background task in the request context.

A normal stop can drain these tasks. A sudden process failure can remove them.

Use a durable queue for work that must survive a process failure.

Memory storage disappears

Memory storage for a Durable Object exists only during the workerd process life.

Local disk storage persists data, but it stays with one runtime instance.

One Worker can use all machine resources

The standalone limit enforcers do not stop a trusted Worker from excessive CPU or memory use.

The operating environment must apply these limits.

A compatibility date does not freeze dependencies

The compatibility system keeps selected Worker API behavior.

The operator must still update workerd for V8 and runtime security corrections. Native dependencies and operating systems also change.

Local parity has limits

Wrangler uses workerd, so local JavaScript behavior closely matches production behavior.

Local storage services are simulations or separate implementations. A local computer does not reproduce global routing, placement, or distributed failures.

Reasons for the architecture

workerd separates the system into clear layers:

Cap'n Proto config   declares services and capabilities
server               opens sockets and builds the service graph
WorkerService        selects a Worker and entrypoint
WorkerEntrypoint     connects an event to one Worker execution
IoContext            owns request I/O and background work
Worker::Isolate      owns the V8 execution environment
JSG                  exposes native APIs to JavaScript
GlobalScope          converts HTTP and calls the handler
KJ                   carries asynchronous I/O and streams

Each layer answers one type of question.

The service graph selects the request destination. Bindings select the capabilities for a Worker.

Compatibility flags select runtime behavior. IoContext selects the owner of each native resource.

JSG defines the JavaScript view of C++ APIs. KJ moves the response to the socket.

These boundaries correspond to important runtime risks.

Configuration prevents accidental access. Isolates separate JavaScript heaps.

Context ownership prevents resource use across requests. Compatibility dates prevent unplanned behavior changes.

Ideas for other applications

The workerd design gives useful patterns for smaller systems:

A runtime controls boundaries around the engine

workerd contains V8 and an HTTP server. That description does not explain the complete system.

The service graph controls reachability. Bindings control capabilities.

JSG controls the native APIs that enter JavaScript. The isolate lock controls execution.

IoContext controls native-resource life. Compatibility dates control behavior changes.

The fetch() handler stays small because the runtime manages these boundaries.

A server runtime does more than run JavaScript. It defines the meaning of JavaScript in an asynchronous server process.

A small educational version

A small learning project does not need Web Crypto, Durable Objects, or Node.js compatibility.

The first version can contain one process and one JavaScript module.

One process and one handler

The server converts an incoming request to a standard Request. It calls an exported fetch() handler.

The server then converts the returned Response to native HTTP data.

The first version does not need multiple isolates, a service graph, or dynamic Worker loading.

One explicit environment object

The env object contains only the required capabilities:

const env = {
  DATABASE: databaseClient,
  EMAIL: emailClient,
}

This object uses the binding concept without a configuration language.

One request context

The ctx object contains these values:

After the response, the server drains the promises for a limited time.

Streaming request and response bodies

The server keeps the request and response bodies as web streams.

The first version does not put each upload or download into one memory buffer.

Isolation for a real security requirement

One JavaScript process is sufficient when the server runs only trusted code.

A service that runs code from several users has a different requirement. That service needs a strong sandbox before it needs more APIs.

The first design has this structure:

flowchart LR
  accTitle: A small educational Worker runtime
  accDescr: An HTTP server creates standard web objects and calls one JavaScript handler with explicit environment and context objects.
  Client["HTTP client"] --> Server["HTTP server"]
  Server --> Request["Web Request"]
  Request --> Handler["JavaScript fetch handler"]
  Env["Explicit env capabilities"] --> Handler
  Context["Abort, logs, waitUntil"] --> Handler
  Handler --> Response["Web Response"]
  Response --> Client

Start with one process, one handler, explicit capabilities, one request context, and streams.

Add a service graph when the system needs independently configured services.

Add compatibility versions when different API behaviors must operate at the same time.

Add isolated execution when one application can block another application.

Add a hardened sandbox and resource limits before users can supply code.

workerd shows the result of these decisions after many years of production use.

Its architecture is a useful reference. It is not a mandatory checklist.

~~~

Related posts about cloudflare: