Background tasks
Some work has no interface to call — it just needs to run: a metrics flusher,
a cache warmer, a queue consumer, a heartbeat. You want it to start with the
application and stop cleanly when the application shuts down, without exposing a
service that anyone yields. Layer.effectDiscard is built for exactly this. It
turns an Effect into a Layer that provides no services — its only job is
the side effect it runs while the layer is alive.
import { NodeRuntime } from "@effect/platform-node"import { Effect, Layer } from "effect"
// `Layer.effectDiscard` runs an effect during layer construction and provides// nothing. We use it to launch a background loop.const BackgroundTask = Layer.effectDiscard( Effect.gen(function*() { yield* Effect.logInfo("Starting background task...")
yield* Effect.gen(function*() { while (true) { yield* Effect.sleep("5 seconds") yield* Effect.logInfo("Background task running...") } }).pipe( // Observe shutdown so we can log (and clean up) when interrupted. Effect.onInterrupt(() => Effect.logInfo("Background task interrupted: layer scope closed") ), // `forkScoped` runs the loop on a child fiber attached to the layer's // scope. When that scope closes, the fiber is interrupted automatically. Effect.forkScoped ) }))
// `Layer.launch` builds the layer and keeps it running forever. The background// fiber is interrupted when the program is stopped (e.g. Ctrl+C / SIGTERM).BackgroundTask.pipe(Layer.launch, NodeRuntime.runMain)Why forkScoped and not a plain loop
Section titled “Why forkScoped and not a plain loop”If you wrote the infinite loop directly inside effectDiscard without forking,
the layer would never finish constructing — Layer.launch would block on the
loop forever and the rest of your layer graph would never come up. Forking moves
the loop onto its own fiber so construction returns immediately while the work
runs concurrently.
Effect.forkScoped is the key choice for lifecycle safety:
- It attaches the new fiber to the layer’s scope, not to the calling fiber.
- When that scope closes — at shutdown, or when the layer is torn down — the fiber is interrupted automatically. No leaked background fibers.
- Your
Effect.onInterrupthandler then runs, giving you a place to flush buffers, close connections, or log a clean exit.
Composing background tasks with the rest of your app
Section titled “Composing background tasks with the rest of your app”Because it is just a Layer, a background task composes with your service
layers like anything else. Merge it into your application layer and it starts
and stops alongside your services — typically you also Layer.provide it any
services it depends on.
import { Effect, Layer } from "effect"
// Pretend these come from elsewhere in the app.declare const MetricsService: Layer.Layer<never>declare const flushMetrics: Effect.Effect<void>
// A background flusher that depends on no exposed interface of its own.const MetricsFlusher = Layer.effectDiscard( Effect.gen(function*() { yield* Effect.gen(function*() { while (true) { yield* Effect.sleep("30 seconds") yield* flushMetrics } }).pipe( Effect.onInterrupt(() => Effect.logInfo("Flushing metrics one last time")), Effect.forkScoped ) }))
// The flusher starts when `AppLayer` is built and is interrupted when it is// torn down. `mergeAll` runs both layers; provide it the metrics service.const AppLayer = Layer.mergeAll(MetricsService, MetricsFlusher)