Skip to content

Runtime & entrypoint

Every deployable needs a file that starts the process and a graph that describes the app. This page answers how those are split, what runs the program, and what changes between the Node, Bun and Cloudflare Worker profiles.

Split bin.ts (the process) from main.ts (the graph)

Section titled “Split bin.ts (the process) from main.ts (the graph)”

Impact: HIGH the graph can be imported without starting a server

  • bin.ts holds only process concerns: the platform layers, the ConfigProvider, the telemetry slot, Layer.launch and runMain. It imports no feature except MainLayer, and no module imports it.
  • main.ts exports MainLayer and has no side effects at import, so a test or a second entry can import it. No entry guard (import.meta.main, isEntrypoint): the file split is what makes importing safe.
  • ConfigProviderLayer is defined in main.ts and provided in bin.ts, beneath the server layer. Otherwise Config.Port("PORT") reads only the process environment and silently ignores .env.
  • Accepted cost: provide order in bin.ts is load-bearing and nothing checks it. A wrong order still compiles.

❌ Incorrect — one file that runs at import, behind a guard:

apps/server/src/index.ts
export const MainLayer = Layer.mergeAll(HttpLayer, Outbox.dispatcherLayer)
if (import.meta.main) { // undefined on some Node versions: exits 0, no output
MainLayer.pipe(Layer.provide(NodeHttpServer.layer(() => createServer())), Layer.launch, NodeRuntime.runMain)
}

✅ Correct — the process in bin.ts, the graph in main.ts:

// apps/server/src/bin.ts — the only module with a side effect
MainLayer.pipe(
Layer.provide(NodeHttpServer.layerConfig(() => createServer(), {
port: Config.Port("PORT"),
gracefulShutdownTimeout: Config.succeed("10 seconds"),
})),
Layer.provide(Observability.layer), // outermost of our layers: closes after the graph
Layer.provide(ConfigProviderLayer), // beneath the server and Observability, so both read .env
Layer.provide(NodeServices.layer), // ConfigProviderLayer needs FileSystem
Layer.launch,
NodeRuntime.runMain,
)

Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 1, amended

Run the server as Layer.launch(MainLayer) → runMain

Section titled “Run the server as Layer.launch(MainLayer) → runMain”

Impact: HIGH signals, the last-resort log and exit codes come for free

  • With HttpRouter.serve the server is a layer, so Layer.launch is the whole program. No Effect.gen main body for the server. Startup work is a Layer.effectDiscard in main.ts, for example the Ledger.requireApplied check that refuses a database that is behind.
  • NodeRuntime.runMain with no options: no disableErrorReporting, no custom teardown, no process.on(...) handlers. It is the last-resort boundary from error boundaries.
  • No ManagedRuntime in a deployable we own. A short-lived command (a one-shot script, a migrate step) gets its own bin file and may use program.pipe(Effect.provide(MainLayer), runMain).
  • MainLayer wraps the app in Lifecycle.drainOnShutdown outermost, so its finalizer runs first (see lifecycle and shutdown).

❌ Incorrect — a generator body whose real work is waiting:

const program = Effect.gen(function* () {
yield* runMigrationsIfNeeded() // boot logic escapes the graph's ordering
yield* Effect.never
})
program.pipe(Effect.provide(MainLayer), NodeRuntime.runMain({ disableErrorReporting: true }))

✅ Correct — startup work is a layer; the program is launch:

main.ts
export const ServicesLayer = Layer.mergeAll(Labeling.layer, Billing.layer).pipe(
Layer.provide(Layer.effectDiscard(Ledger.requireApplied(/* manifest */))),
)
export const MainLayer = Lifecycle.drainOnShutdown.pipe(
Layer.provideMerge(AppLayer),
Layer.provide(Lifecycle.layer),
)
// bin.ts: … Layer.launch, NodeRuntime.runMain

Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 2, amended

Keep platform code in the platform entry file

Section titled “Keep platform code in the platform entry file”

Impact: MEDIUM the graph loads on any runtime; a switch touches one file

  • Platform code means @effect/platform-node, @effect/platform-bun, node:*, alchemy, alchemy/* and cloudflare:*. It appears only in the platform entry file: bin.ts (or bin/*.ts) for Node and Bun, isolate.ts for a Worker, plus packages/db/src/migrate.ts and alchemy.run.ts.
  • main.ts, http.ts and feature folders depend on abstract services: HttpServer.HttpServer, FileSystem.FileSystem, HttpClient.HttpClient.
  • One exception: node:crypto is allowed in infra/secrets.ts and infra/secret-box.ts, because effect/Crypto has no cipher, HMAC or constant-time compare. On a Worker it needs nodejs_compat.
  • The rule is lint-enforced with a no-restricted-imports override (tests exempt); see linting and formatting and coupling and cohesion.

❌ Incorrect — the graph picks the platform:

main.ts
const ServerLayer = typeof Bun !== "undefined"
? BunHttpServer.layerConfig({ port: Config.Port("PORT") })
: NodeHttpServer.layerConfig(() => createServer(), { port: Config.Port("PORT") })

✅ Correct — the graph stays abstract; the entry supplies the server:

main.ts
const AppLayer = Layer.mergeAll(
HttpRouter.serve(Layer.mergeAll(RoutesLayer, Health.readinessLayer)),
Outbox.dispatcherLayer,
)
// still requires: HttpServer.HttpServer | FileSystem.FileSystem — supplied by bin.ts

Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 3, amended

Run background work as layers in the same process

Section titled “Run background work as layers in the same process”

Impact: MEDIUM one deployable, nothing wired twice

  • An outbox dispatcher, a recurring job or a queue consumer is a scoped layer merged into MainLayer. It uses the helper-owned worker boundary from error boundaries, so one bad item never ends the loop.
  • A recurring job is a value exported by its slice (Jobs.make). main.ts exports jobs and subscribers, and on the container profiles merges Jobs.layer(jobs) into the graph.
  • Every replica runs every loop. A job that must not run twice is declared exclusive: true and takes a jobs_leases row (see scheduling and retry). The outbox relay and its cleanup do not need the lease.
  • Accepted cost: background work cannot scale apart from requests.

❌ Incorrect — a second process and app before any job needs one:

apps/server/ ← HTTP
apps/worker/ ← outbox + jobs, its own graph, pool and deploy pipeline

✅ Correct — loops are layers beside the server:

const AppLayer = Layer.mergeAll(
HttpRouter.serve(Layer.mergeAll(RoutesLayer, Health.readinessLayer)),
Outbox.dispatcherLayer, // every background loop is a layer
Jobs.layer(jobs), // one loop per recurring job, on every replica
).pipe(
Layer.provide(ServicesLayer), // shared with the loops → outside serve
Layer.provide(SqlLayer), // infrastructure last
)

Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 4, amended

Pick a runtime profile: Node (default), Bun, or a Cloudflare Worker

Section titled “Pick a runtime profile: Node (default), Bun, or a Cloudflare Worker”

Impact: MEDIUM container decisions stay put; Worker differences are explicit

  • main.ts exports RoutesLayer, ServicesLayer, jobs and subscribers for every profile, and MainLayer for the long-lived profiles (it adds serve, the loops, /ready and Lifecycle.drainOnShutdown).
  • Node is the default for a long-lived server. Bun is supported: a Bun project changes bin.ts and its SQLite driver only. Bun is the runtime; pnpm stays the package manager.
  • The Worker profile is deployed by alchemy. Its entry is isolate.ts (not worker.ts, because “Worker” names our makeWorker consumer).
Node Bun Cloudflare Worker
entry bin.ts bin.ts isolate.ts
program Layer.launch → NodeRuntime.runMain Layer.launch → BunRuntime.runMain none: alchemy’s bridge calls fetch
SQL Postgres or SQLite Postgres or SQLite D1 (@effect/sql-d1)
probes /health + /ready same /health only
jobs Jobs.layer(jobs) same one Cron Trigger per job
Crypto in NodeServices in BunServices Crypto.make over globalThis.crypto

❌ Incorrect — Bun-only behaviour leaks into the graph:

main.ts
const port = Bun.env.PORT // the graph no longer loads on Node or a Worker

✅ Correct — only the entry file differs per profile:

// bin.ts on Bun
MainLayer.pipe(
Layer.provide(BunHttpServer.layerConfig({ port: Config.Port("PORT") })),
Layer.provide(ConfigProviderLayer),
Layer.provide(BunServices.layer),
Layer.launch,
BunRuntime.runMain,
)

Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 5

On a Worker, build the graph once per isolate, in init

Section titled “On a Worker, build the graph once per isolate, in init”

Impact: HIGH building in fetch rebuilds every layer on every request

  • alchemy evaluates the init closure once per isolate. Build ServicesLayer there with Layer.build, hand the context to HttpRouter.toHttpEffect(RoutesLayer) through Layer.succeedContext, and to each cron handler with Effect.provide.
  • Call toHttpEffect once. It builds with a forked MemoMap, so handing it the whole graph and building the jobs’ services again builds every layer twice.
  • Config reads must happen inside init: alchemy binds them as Worker secrets only there.
  • Nothing disposable is acquired at init. The build scope is never closed, so a finalizer added there never runs. A resource that needs a release is acquired per request. D1 is a binding, not a pool, so D1Client at init is fine.

❌ Incorrect — the graph built inside fetch:

return {
fetch: Effect.gen(function* () {
// a full graph build per request, and Config read after init
const handler = yield* HttpRouter.toHttpEffect(RoutesLayer.pipe(Layer.provide(ServicesLayer)))
return yield* handler
}),
}

✅ Correct — one build in init, shared by fetch and cron:

Effect.gen(function* () {
const d1 = yield* Cloudflare.D1.QueryDatabase(yield* Database)
const services = yield* Layer.build(ServicesLayer.pipe(
Layer.provideMerge(D1Client.layer({ db: yield* d1.raw })),
Layer.provideMerge(WorkerCrypto),
))
const handler = yield* HttpRouter.toHttpEffect(RoutesLayer.pipe(Layer.provide(Layer.succeedContext(services))))
for (const job of jobs) {
yield* Cloudflare.Workers.cron(job.cron, (controller) =>
Jobs.run(job, controller.scheduledTime).pipe(Effect.provide(services)))
}
return { fetch: handler }
})

Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 5, amended

  • A second process kind (bin/server.ts, bin/worker.ts) — trigger: a background workload is measured to raise request p95 by more than 20% while it runs.
  • Splitting main.ts into a shared service root and an HTTP root — trigger: a second bin exists.
  • A separate app per process kind — trigger: a second team owns the process.
  • Providing observability with Effect.provide after Layer.launch, around a boot-failure reporter — trigger: a boot-failure alert is wired.
  • A Durable Object per org terminating the RPC WebSocket on the Worker — trigger: the RPC contracts trigger fires.