Adopting Effect in One Place Only, Measured

The question arrived in plain words: would the Effect library harden Stem Shovel's server code and make its error handling uniform? Effect is a TypeScript library for programs with typed errors, typed dependencies, timeouts that interrupt the work, and resources that are released on every path. It is also a second way of writing code next to async/await, and a dependency that would land in every server bundle. The honest answer took a survey, a day of plain fixes, a pilot in one file, and a table of numbers. This is that answer, including the parts where we said no.
What the Survey Found
Stem Shovel's server has two halves. One is the request side: 204 remote functions at the time of the survey (238 today), each a short query or command that throws error() or invalid() at the boundary and lets one handler turn that into a message for the client. That contract was already uniform, and valibot already validated every input at that boundary. There was nothing for Effect to fix there.
The other half is the media pipeline behind POST /api/jobs: one serverless function that transcodes stems with ffmpeg, renders mixes, transcribes notes with a TensorFlow child process, builds notation PDFs and DAWproject exports, and sends the notification digest. Reading it as a stranger would, we wrote down what it actually did with failure:
- Thirteen job kinds lived in one
switch, each a serialfor ... awaitloop over ids. A throw on one id dropped every id after it, and in the stem renders it dropped the songs' mixes and notes too. - The
background()helper that ran a job after the response only calledconsole.error. No server module reported to Sentry; the hooks file was the onlycaptureExceptionin the repo. - No ffmpeg run had a timeout, and no child process was ever killed. The TensorFlow child was the one process with a bound.
- The mix job downloaded every stem at once, and when one download failed the
finallyremoved the temp directory while the others were still writing into it. - There were no error classes. Fifty-three server throws were
new Error(string), and callers that needed to tell failures apart matched on the message text. - The transcode test mocked nine modules. There were 47
vi.mockcalls across 18 server test files.
That is the shape Effect is built for. But reading the list again, three of the worst problems had five-line fixes that needed no library. Reporting background failures to Sentry is one call. Isolating one id's failure from the next is a try/catch inside the loop. Putting a timeout on ffmpeg is an option on execFile. If those landed inside the pilot, the pilot would get credit for them, and we would be judging a library on work any helper could have done.
The Plain Fixes Went First
So the first commit had no Effect in it. It added a reportError() wrapper around Sentry.captureException with the job kind as a tag, taught background() to call it, and replaced every per-id loop with one helper:
export async function eachJob(
kind: string,
ids: readonly string[],
work: (id: string) => Promise<void>,
): Promise<void> {
for (const id of ids) {
try {
await work(id);
} catch (e) {
console.error("[jobs]", kind, id, e);
reportError(e, { job: kind }, { id });
}
}
}
Every ffmpeg call got a timeout option from one constant (240 seconds then, raised to 600 on 2026-10-28 so a three-hour show's encode fits inside it). The transcode tests that had expected a throw now expected a report. Twenty-two lines for the loop helper, seventeen for the reporter, and the three biggest holes were closed.
That ordering is the part of this story we would repeat anywhere. A pilot is a comparison, and a comparison is only fair when both sides start from the same baseline. With the plain fixes shipped, whatever the pilot bought would be structure and uniformity, which is what the question was about.
The Pilot: One Job Behind Its Old Signature
We picked the mix job, src/lib/server/mix.ts, 249 lines. It claims a row in the database, downloads N stems to a temp directory, runs ffmpeg, uploads the result, points the song at it, deletes the old mix, and releases the claim on failure. It touches every concern once and had a four-mock test. Its exported function, ensureOriginalMix(songId: string): Promise<void>, would keep its signature, so no caller would change and the kill switch was one git revert plus removing the dependency.
A small foundation went into src/lib/server/effect/: three tagged error classes (BlobError, DbError, FfmpegError), three services (BlobStore, Db, Ffmpeg) whose live layers wrap the modules that already existed, a runtime built once per function instance, and a fourteen-line runJob() that is the whole edge between Effect and the Promise world. That is 221 lines, shared by every job that followed.
Inside mix.ts the shape changed in four places. The claim became a resource that releases itself when the work fails or is interrupted:
const claimed = yield* Effect.acquireRelease(
db.claimSongMix(songId, key),
(claimed, exit) =>
claimed && Exit.isFailure(exit)
? Effect.ignore(db.releaseSongMix(songId))
: Effect.void,
);
The temp directory became a scoped resource, removed on success, failure and interruption alike. The downloads became Effect.forEach with concurrency: 3, so one failure interrupts its siblings instead of racing their writes against the cleanup; each download retries a transient failure with jittered exponential backoff. And ffmpeg runs under Effect.timeout, where the service's register function returns the cleanup that runs on interruption:
const child = execFile(ffmpegPath, args, options, (error, stdout, stderr) => {
/* resume with the result or an FfmpegError */
});
return Effect.sync(() => {
child.kill();
});
A timeout now stops the wait and the process. The old execFile timeout option did the same for a single call, but the Effect version composes: the same interruption that kills ffmpeg also stops the sibling downloads and removes the directory, in order, without any of that being written by hand in the job.
What It Bought
The test is where the difference was easiest to see. The old mix.test.ts was 148 lines and tested the pure parsers only, because exercising the job meant mocking four modules. The new one is 297 lines, provides three fake layers in place of the live services, and adds six behavior cases: the happy path, a stem that answers 404 (fails at once with a BlobError, no retry, claim released, directory gone), a read that answers 503 twice and then 200 (retried, job succeeds), an ffmpeg that never finishes (times out, claim released, directory gone), a failing setSongMix (a DbError, claim released), and a refused claim (renders nothing). The vi.mock count went from four to two, and the two that remain only keep the import of the live layers from opening a database in CI.
A separate service test runs a real ffmpeg on a silent source with no end, times it out after 300 milliseconds, and checks with pgrep that the process is gone. That test would have been awkward to write against the old code and is a short one here.
One thing the typed errors caught that we had not thought to look for: the first draft's retry predicate retried every thrown read, including the Blob module's own refusal of a URL outside our stores, which is a bug and not weather. The rule is now explicit: retry on a 5xx status, on undici's TypeError: fetch failed, or on a system error with a string code. Nothing else.
What It Cost
The numbers, measured and written into the plan document at the end:
| Measure | Before | After |
|---|---|---|
mix.ts lines |
249 | 363, plus the 221-line foundation shared from here on |
mix.test.ts lines |
148 (parsers only) | 297, with six behavior cases |
vi.mock calls in the test |
4 | 2 |
| Mix chunk in the server bundle | 70 kB minified, 24 kB minified and gzipped | |
| Jobs function cold start | 4.46 s (warm 0.13 to 0.18 s) | 4.34 s (warm 0.12 to 0.17 s) |
The cold start was measured on an untouched preview deployment, first hit cold and then warm, against the function's own 401 response. The difference is inside run-to-run noise, which is what we expected for a function that already carries ffmpeg and TensorFlow. A page function would have been a different question, and that is one reason Effect stayed out of page code.
Two costs were not on the advertised label. Effect's own figures are 7 kB minified and gzipped for a minimal program and about 18 kB for a typical one; our chunk measured 24 kB with the job included, and the pre-minified chunk was 298 kB because Effect ships its documentation comments. And importing effect unbundled from node_modules costs about 0.2 seconds on Node 24 at a cold start, so it had to join Vite's ssr.noExternal and be bundled in. Neither is a reason to say no, both are things to know before you do.
The cost that does not fit in a table is the second mental model. Whoever reads this server now needs generators, layers and scopes next to async/await, and effect@4.0.6 was three and a half weeks old when we adopted it, with a patch release every few days. The plan named that cost and set a decisive criterion for it: someone reads the new mix.ts cold and says whether they could change it.
Two Traps on the Way
Effect 4 renamed a great deal of Effect 3, and most of what has been written about the library, including what a language model knows about it, describes version 3. Context.Tag is Context.Service. catchAll is Effect.catch, exported under the alias catch_ because catch is a keyword, so grepping the type declarations for export declare const catch finds nothing. Effect.async is Effect.callback. The timeout error is Cause.TimeoutError, an interface with a _tag and not a class, so instanceof is the wrong test. And a get message() on a Data.TaggedError subclass collides with Error's own property; assigning this.message after super() works. Each of these was a trip to the installed .d.ts files, which turned out to be the one reliable reference.
The second trap was not Effect's. When the temp-directory helper moved into the shared foundation and took its prefix as a parameter, the Vercel build failed with ENXIO on /tmp/.X11-unix/X98. Vercel's file tracer, @vercel/nft, statically evaluates path expressions to find files a function depends on, and mkdtemp(join(tmpdir(), prefix)) with a dynamic prefix evaluated to a glob over all of /tmp on the build machine, where it met an X11 socket. The fix is a function that does nothing:
function tmpBase() {
return tmpdir();
}
The tracer does not look through a local function call, so the static evaluation stops there. It is the kind of fix that deserves its comment, and it has one.
Where We Said No
The remote functions never got Effect, and will not. Their error contract was already uniform, and uniformity was the point.
The client got one pilot, five days later, and that one was reverted. The song page has a class that runs a pipeline (transcribe, match, AI check, draft) with hand-rolled cancellation tokens, which is the one client shape that fits. Rewriting it on Effect's fibers worked and the tests passed unchanged, but the song page's chunk went from 57.0 to 68.6 kB gzipped, a 20 percent increase, to replace six cancelled() checks and one error class. Not worth it. The two behavior tests the rewrite added (a second Detect stops the first; Cancel during the draft drops its answer) stayed, because they pass on the token code too.
The General Lesson
The pilot was a go, and the rest of the pipeline followed the same day: the transcodes, the notes, the PDFs, the exports, an AI service with one shared parse-retry loop, the digest with a time budget, and the thirteen-case switch collapsed into a table of kind to runner. A re-measurement after all of it put the jobs function at 4.52 seconds cold against 4.46 before, again inside the noise. We have written elsewhere about the one fat function that runs these jobs and about how this side project refused to stay small; the pipeline described here is the part of it that failed quietly for longest.
None of that is the lesson. The lesson is the order of operations. Ship the plain fixes first, so the library is judged on what only the library can do. Pick one bounded place with a signature you will not change, so reverting is one commit. Decide what you will measure before you start, measure it after, and write the numbers where the next person will find them. And be willing to run the same experiment in the next place and get a different answer, which is what the client pilot was for. A library that earns its place in one file has earned exactly that.