An Effect-TS application running on Cloudflare Workers with request-scoped database connections using HttpApiMiddleware.
Note: This project previously used a FiberRef-based approach for request-scoped dependencies. We've since migrated to
HttpApiMiddleware, which provides a cleaner solution:
- Standard Effect patterns β Use
yield* DatabaseServiceinstead of custom accessors- Compile-time type safety β Missing middleware causes type errors, not runtime failures
- Granular control β Apply middleware at the API or group level (not all endpoints need a database)
For the original FiberRef approach, see the fiber-ref-poc branch.
Cloudflare Workers can't share TCP connections between requests. Each request needs its own database connection that gets created, used, and cleaned up within that request's lifetime.
Traditional Node.js Server:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Server Process β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β Connection Pool (shared across requests) β β
β β ββββββ ββββββ ββββββ ββββββ ββββββ β β
β β βconnβ βconnβ βconnβ βconnβ βconnβ β β
β β ββββββ ββββββ ββββββ ββββββ ββββββ β β
β βββββββββββββββββββββββββββββββββββββββββββββββ β
β β β β β
β Request 1 Request 2 Request 3 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Cloudflare Worker:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Worker Isolate β
β β
β Request 1: [open conn] β [query] β [close conn] β
β Request 2: [open conn] β [query] β [close conn] β
β Request 3: [open conn] β [query] β [close conn] β
β β
β No shared state. No connection pool. β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
In Effect, you typically define services with Context.Tag and compose them with Layer:
// This is the "normal" Effect pattern
class Database extends Context.Tag("Database")<Database, DrizzleInstance>() {
static Live = Layer.effect(this, makeDrizzleClient())
}
// Use in handlers
const getUsers = Effect.gen(function* () {
const db = yield* Database
return yield* db.select().from(users)
})The problem: Layers are memoized.
When you create a ManagedRuntime, it builds all layers once at startup:
const runtime = ManagedRuntime.make(
Layer.mergeAll(
Database.Live, // Built ONCE at startup
HttpRouter.Live, // Built ONCE at startup
HttpMiddleware.Live, // Built ONCE at startup
)
)For Cloudflare Workers, env is available at startup, so we could create a database layer. But that layer would open a TCP connection once and try to reuse it across requests. This fails immediately on the second request:
ManagedRuntime with Database Layer:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Module Initialization (once) β
β β
β ManagedRuntime.make(layers) β
β β β
β Build HttpRouter.Live β β
β Build Database.Live β β
β β β
β Opens TCP connection to Postgres β
β β β
β Connection stored in layer (memoized) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Request 1: Uses memoized connection β β
β Request 2: CRASH β β
β Request 3: Fresh isolate, works β β
β Request 4: CRASH β β
β ...every other request fails β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The exact error from Cloudflare:
Error: Cannot perform I/O on behalf of a different request.
I/O objects (such as streams, request/response bodies, and others)
created in the context of one request handler cannot be accessed
from a different request's handler. This is a limitation of
Cloudflare Workers which allows us to improve overall performance.
This isn't about connections going stale - Cloudflare actively prevents I/O objects from being shared between requests. The TCP socket opened during Request 1 cannot be used by Request 2. Period.
Effect's HttpApiMiddleware runs per-request, making it the perfect mechanism for request-scoped services. Unlike layers which are memoized at startup, middleware effects execute fresh for each request.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ManagedRuntime (built once at startup) β
β β
β HttpApiBuilder.api(WorkerApi) β Router config β
β HttpApiBuilder.Router.Live β Router instance β
β CloudflareBindingsMiddlewareLive β Middleware implβ
β DatabaseMiddlewareLive β Middleware implβ
β β
β Note: Middleware IMPLEMENTATIONS are memoized, β
β but the middleware EFFECTS run per-request! β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
runtime.runPromise(effect)
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Per-Request (via HttpApiMiddleware) β
β β
β CloudflareBindingsMiddleware β yield* effect β
β βββ Provides { env, ctx } to handlers β
β β
β DatabaseMiddleware β yield* effect β
β βββ Opens TCP connection β
β βββ Creates Drizzle instance β
β βββ Provides { drizzle } to handlers β
β βββ Connection closed when request ends β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Middleware uses HttpApiMiddleware.Tag with a provides option to inject services:
// src/services/database.ts - Service definition
export class DatabaseService extends Context.Tag("DatabaseService")<
DatabaseService,
{ readonly drizzle: DrizzleInstance }
>() {}
// Error type with HTTP status annotation
export class DatabaseConnectionError extends S.TaggedError<DatabaseConnectionError>()(
"DatabaseConnectionError",
{ message: S.String },
HttpApiSchema.annotations({ status: 503 }),
) {}
// src/http/middleware/database.ts - Middleware definition
export class DatabaseMiddleware extends HttpApiMiddleware.Tag<DatabaseMiddleware>()(
"DatabaseMiddleware",
{
failure: DatabaseConnectionError, // Possible errors
provides: DatabaseService, // Service to inject
},
) {}The middleware implementation returns an Effect that runs per-request:
export const DatabaseMiddlewareLive = Layer.effect(
DatabaseMiddleware,
Effect.gen(function* () {
// This outer Effect runs once (layer construction)
// Return the inner Effect that runs per-request
return Effect.gen(function* () {
// Read env from FiberRef (set at entry point)
const env = yield* FiberRef.get(currentEnv)
if (env === null) {
return yield* Effect.fail(
new DatabaseConnectionError({ message: "Env not available" })
)
}
// Create scoped connection (auto-closes when request ends)
const pgClient = yield* PgClient.make({
url: Redacted.make(env.DATABASE_URL),
}).pipe(Effect.provide(Reactivity.layer))
const drizzle = yield* PgDrizzle.make({
casing: "snake_case",
}).pipe(Effect.provideService(SqlClient.SqlClient, pgClient))
return { drizzle }
}).pipe(
Effect.catchAll((error) =>
Effect.fail(new DatabaseConnectionError({
message: `Connection failed: ${error}`
}))
)
)
}),
)// src/http/api.ts - Apply at API level
export class WorkerApi extends HttpApi.make("WorkerApi")
.add(HealthGroup)
.add(UsersGroup)
.middleware(CloudflareBindingsMiddleware) // Available everywhere
.prefix("/api") {}
// src/http/groups/users.definition.ts - Apply at group level
export const UsersGroup = HttpApiGroup.make("users")
.add(HttpApiEndpoint.get("list", "/").addSuccess(UsersListSchema))
.middleware(DatabaseMiddleware) // Only for this group
.prefix("/users")Handlers use standard Effect service access - no special patterns needed:
// src/http/groups/users.handlers.ts
export const UsersGroupLive = HttpApiBuilder.group(
WorkerApi,
"users",
(handlers) =>
Effect.gen(function* () {
return handlers.handle("list", () =>
Effect.gen(function* () {
// Standard Effect pattern - type-safe!
const { drizzle } = yield* DatabaseService
const dbUsers = yield* drizzle
.select()
.from(users)
return { users: dbUsers, total: dbUsers.length }
}),
)
}),
)fetch(request, env, ctx)
β
βββ withCloudflareBindings(env, ctx)
β βββ Sets FiberRef (bridge to middleware)
β
βββ runtime.runPromise(handleRequest(request))
β
βββ CloudflareBindingsMiddleware runs
β βββ Reads FiberRef
β βββ Provides { env, ctx } via Context
β
βββ DatabaseMiddleware runs
β βββ Reads env.DATABASE_URL from FiberRef
β βββ Opens TCP connection (scoped)
β βββ Provides { drizzle } via Context
β
βββ Handler executes
β βββ yield* DatabaseService β gets { drizzle }
β βββ yield* CloudflareBindings β gets { env, ctx }
β
βββ Response returned
βββ Request scope ends
βββ TCP connection automatically closed
The middleware pattern provides compile-time guarantees:
// If DatabaseMiddleware isn't applied to the group, this won't compile!
const { drizzle } = yield* DatabaseService
// ^^^^^^^^^^^^^^^
// Error: Service 'DatabaseService' is not available in the current contextThe entry point is minimal - just bridge Cloudflare bindings to Effect:
// src/index.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const effect = handleRequest(request).pipe(
withCloudflareBindings(env, ctx), // Bridge to Effect
)
return runtime.runPromise(effect)
},
}Cloudflare Queues use a different pattern than HTTP. Instead of HttpApiMiddleware, we use makeQueueHandler - a factory that creates batch-scoped Effect handlers with automatic ack/retry semantics.
- No HTTP routing β Single handler per queue, no middleware chain
- Batch processing β Receives
MessageBatch<T>, not single requests - Ack/retry semantics β Must signal success or failure per message
// src/index.ts
import { makeQueueHandler } from "@/queue"
export default {
fetch: ...,
queue: makeQueueHandler({
schema: MyEventSchema,
handler: (event) =>
Effect.gen(function* () {
const { drizzle } = yield* DatabaseService
yield* Effect.log("Processing event", event)
}),
concurrency: 5,
}),
}- Schema validation β Messages are decoded with Effect Schema
- Batch-scoped resources β One DB connection per batch (not per message)
- Automatic ack/retry β Effect success β
ack(), failure βretry()or dead-letter
queue(batch, env, ctx) called
β
βββ Build batch-scoped layer
β βββ CloudflareBindings (immediate)
β βββ DatabaseService (opens connection)
β
βββ Effect.forEach(messages, processWithAckRetry)
β β
β βββ Message 1: decode β handler β ack()
β βββ Message 2: decode β handler β ack()
β βββ Message 3: decode fails β dead-letter
β βββ Message 4: handler fails (retryable) β retry()
β βββ Message 5: decode β handler β ack()
β
βββ Batch complete
βββ Database connection closed
Control retry behavior with QueueProcessingError.retryable:
const handleEvent = (event: MyEvent) =>
Effect.gen(function* () {
// Business validation - don't retry invalid data
if (!isValid(event)) {
return yield* Effect.fail(
new QueueProcessingError({
message: "Invalid event",
retryable: false, // Dead-letter
}),
)
}
// DB operation - retry on transient failures
yield* Effect.tryPromise({
try: () => db.insert(...),
catch: (error) =>
new QueueProcessingError({
message: "DB insert failed",
retryable: true, // Retry later
cause: error,
}),
})
})| Error Type | Retryable | Action |
|---|---|---|
| Schema decode error | No | Ack (dead-letter) |
| Business logic error | No | Ack (dead-letter) |
| Database timeout | Yes | Retry |
| Network error | Yes | Retry |
The crucial difference is when the Effect runs:
| Aspect | Layer.effect | HttpApiMiddleware |
|---|---|---|
| Runs when | Once at layer construction | Per-request |
| Resources | Memoized, shared | Fresh each request |
| Good for | Static config, routers | Connections, auth |
| Scoping | Application lifetime | Request lifetime |
// Layer: Effect runs ONCE at startup
const DbLayer = Layer.effect(Database, Effect.gen(function* () {
const conn = yield* openConnection() // Opens once, stays open
return conn
}))
// Middleware: Effect runs PER-REQUEST
const DbMiddleware = Layer.effect(DatabaseMiddleware, Effect.gen(function* () {
return Effect.gen(function* () { // This inner effect runs per-request
const conn = yield* openConnection() // Opens fresh each request
return { drizzle: conn }
})
}))Middleware effects can only access HttpRouter.Provided context (request, route params, etc). They cannot depend on other services via yield* OtherService.
To pass Cloudflare's env and ctx from the entry point (outside Effect) into middleware (inside Effect), we use FiberRef as a bridge:
// Entry point sets FiberRef
withCloudflareBindings(env, ctx) // Sets currentEnv and currentCtx
// Middleware reads from FiberRef (no service dependency)
const env = yield* FiberRef.get(currentEnv) // Works!
// This would NOT work in middleware:
const { env } = yield* CloudflareBindings // Creates dependency, breaks middleware# Start local Postgres
pnpm db:up
# Push schema
pnpm db:push
# Seed data
pnpm db:seed
# Run dev server
pnpm dev
# Type check
pnpm typecheck
# Run tests
pnpm testsrc/
βββ index.ts # Worker entry point (fetch + queue)
βββ runtime.ts # ManagedRuntime for HTTP
βββ services/
β βββ index.ts # Re-exports
β βββ cloudflare.ts # CloudflareBindings service + FiberRef bridge
β βββ database.ts # DatabaseService + makeDatabaseConnection
βββ http/
β βββ api.ts # HttpApi definition
β βββ middleware/
β β βββ cloudflare.ts # CloudflareBindingsMiddleware (HTTP)
β β βββ database.ts # DatabaseMiddleware (HTTP)
β βββ groups/
β β βββ *.definition.ts # Endpoint schemas + group middleware
β β βββ *.handlers.ts # Handler implementations
β βββ schemas/ # Request/response schemas
β βββ errors/ # API error types
βββ queue/
β βββ index.ts # Re-exports
β βββ handler.ts # makeQueueHandler factory
β βββ errors.ts # Queue error types
β βββ handlers/ # Queue handler implementations
βββ db/
βββ schema.ts # Drizzle schema
With middleware, testing uses standard Layer composition:
// Mock the middleware
const MockDatabaseMiddlewareLive = Layer.succeed(
DatabaseMiddleware,
Effect.succeed({ drizzle: mockDrizzle }),
)
// Provide mock in tests
const TestLayer = HttpGroupsLive.pipe(
Layer.provide(MockDatabaseMiddlewareLive),
)// Custom accessor functions
const db = yield* getDrizzle // FiberRef.get + null check
const env = yield* getEnv // FiberRef.get + null check
// Entry point wires everything
effect.pipe(
withDatabase(env.DATABASE_URL),
withEnv(env),
withCtx(ctx),
)// Standard Effect services
const { drizzle } = yield* DatabaseService // Type-safe!
const { env, ctx } = yield* CloudflareBindings // Type-safe!
// Entry point is minimal
effect.pipe(withCloudflareBindings(env, ctx))| Aspect | FiberRef | Middleware |
|---|---|---|
| Type Safety | Runtime null checks | Compile-time service requirements |
| Error Handling | Effect.die on null | Typed errors with HTTP status |
| Standard Pattern | Custom accessors | Standard yield* Service |
| Testing | Wrap with withX() |
Provide mock Layer |