Every FiberRef.set copies the fiber's entire FiberRefs Map — O(R) per mutation, even no-op mutations
Repo: effect-ts/effect
Location: packages/effect/src/internal/fiberRefs.ts:216 (updateAs new Map(self.locals)), reached via fiberRuntime.ts:592 setFiberRef from core.ts fiberRefSet
Severity: high · Confidence: 0.95
Type: complexity-at-a-distance
Description
core.fiberRefSet (packages/effect/src/internal/core.ts) calls FiberRuntime.setFiberRef (packages/effect/src/internal/fiberRuntime.ts:592), which calls fiberRefs.updateAs (packages/effect/src/internal/fiberRefs.ts:192-224). updateAs unconditionally executes const locals = new Map(self.locals) (fiberRefs.ts:216) — a full copy of ALL fiber refs currently set on the fiber — BEFORE unsafeUpdateAs runs, so even a value-unchanged early-return still pays the full copy. setFiberRef then also calls refreshRefCache() which re-derives 6 cached refs. Cross-module consequence: any code doing M FiberRef mutations (FiberRef.set/update, Effect.locally which sets+restores = 2 copies, log annotations, span context) on a fiber where libraries/middleware have populated R refs costs O(M·R). Each file looks fine — updateAs is 'immutable update', setFiberRef is 'one call' — but composed, per-item ref mutation in request loops scales with the total ref population of the app. Validated on effect 3.21.4: 20,000 sets of ONE ref went from 14.5ms with 10 unrelated refs present to 395.7ms with 500 (27.2x, tracking the O(R)-per-set prediction of 50x with a constant offset).
Benchmark
Holding the number of FiberRef.set calls fixed at 20,000 while sweeping the count R of unrelated refs already set on the fiber, median time should grow roughly linearly with R (O(R) per set from the full Map copy), not stay flat as O(1)-per-set would predict. Measured: ratio 27.2x at R=500 vs R=10.
Observed complexity: O(N)
| Input | median_ms | ratio | note |
|-------|-----------|-------|------|
| R=10 | 17.07 | 1.00 | — |
| R=50 | 48.01 | 2.81 | predicts 5.00 |
| R=100 | 103.44 | 6.06 | predicts 10.00 |
| R=500 | 448.91 | 26.30 | predicts 50.00 |
benchmark confirmed
Every FiberRef.set copies the fiber's entire FiberRefs Map — O(R) per mutation, even no-op mutations
Repo:
effect-ts/effectLocation: packages/effect/src/internal/fiberRefs.ts:216 (updateAs
new Map(self.locals)), reached via fiberRuntime.ts:592 setFiberRef from core.ts fiberRefSetSeverity: high · Confidence: 0.95
Type: complexity-at-a-distance
Description
core.fiberRefSet (packages/effect/src/internal/core.ts) calls FiberRuntime.setFiberRef (packages/effect/src/internal/fiberRuntime.ts:592), which calls fiberRefs.updateAs (packages/effect/src/internal/fiberRefs.ts:192-224). updateAs unconditionally executes
const locals = new Map(self.locals)(fiberRefs.ts:216) — a full copy of ALL fiber refs currently set on the fiber — BEFORE unsafeUpdateAs runs, so even a value-unchanged early-return still pays the full copy. setFiberRef then also calls refreshRefCache() which re-derives 6 cached refs. Cross-module consequence: any code doing M FiberRef mutations (FiberRef.set/update, Effect.locally which sets+restores = 2 copies, log annotations, span context) on a fiber where libraries/middleware have populated R refs costs O(M·R). Each file looks fine — updateAs is 'immutable update', setFiberRef is 'one call' — but composed, per-item ref mutation in request loops scales with the total ref population of the app. Validated on effect 3.21.4: 20,000 sets of ONE ref went from 14.5ms with 10 unrelated refs present to 395.7ms with 500 (27.2x, tracking the O(R)-per-set prediction of 50x with a constant offset).Benchmark
Holding the number of FiberRef.set calls fixed at 20,000 while sweeping the count R of unrelated refs already set on the fiber, median time should grow roughly linearly with R (O(R) per set from the full Map copy), not stay flat as O(1)-per-set would predict. Measured: ratio 27.2x at R=500 vs R=10.
Observed complexity: O(N)
benchmark confirmed