Replay-enabled PubSub keeps every published value alive until the next full GC
Maintainers usually reply within 1 day
Assessment
This issue has not been assessed yet.
Description
What steps can reproduce the bug?
Every SubscriptionRef is a PubSub.unbounded({ replay: 1 }). When the replay buffer is full, ReplayBuffer.offer drops its head with this.head = this.head.next!, but the dropped node keeps its value and its next.
If a full GC promoted that node while it was the head, it is now dead but in the old generation, and every scavenge treats its pointers into the young generation as roots until the next full GC. Through next it keeps every later node and its value alive. Those get promoted, and the last promoted node does the same to the nodes after it. So until the next full GC, every published value ends up in the old generation.
The script below sets a fresh value of about 75 KB every 50 ms for 20 s and runs one scavenge every 4 updates, so a replaced value lives through at most one scavenge. It compares a Ref with a SubscriptionRef that has no subscribers. I ran it on Node 26.
npm install effect
node --expose-gc --min-semi-space-size=64 --max-semi-space-size=64 subscriptionRef.mjs
The two semi-space flags make the young generation big enough that allocation does not trigger scavenges, so only the script's gc({ type: "minor" }) calls run them.
subscriptionRef.mjs
// node --expose-gc --min-semi-space-size=64 --max-semi-space-size=64 subscriptionRef.mjs
import v8 from "node:v8"
import { constants, performance, PerformanceObserver } from "node:perf_hooks"
import { Effect, Ref, SubscriptionRef } from "effect"
const makeValue = () => Array.from({ length: 2000 }, (_, index) => ({ index })) // about 75 KB
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const oldGenerationBytes = () =>
v8.getHeapSpaceStatistics()
.filter((space) => !space.space_name.startsWith("new_"))
.reduce((sum, space) => sum + space.space_used_size, 0)
const fullGcStarts = []
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.detail.kind === constants.NODE_PERFORMANCE_GC_MAJOR) fullGcStarts.push(entry.startTime)
}
}).observe({ entryTypes: ["gc"] })
// Sets a fresh value every 50 ms and runs one scavenge every 4 updates, so a value that is
// replaced after one update lives through at most one scavenge and should never be promoted.
const measure = async (name, set) => {
for (let index = 0; index < 20; index++) {
set(makeValue())
await sleep(50)
}
globalThis.gc()
const start = performance.now()
let promotedBytes = 0
for (let index = 1; index <= 400; index++) {
set(makeValue())
await sleep(50)
if (index % 4 === 0) {
const before = oldGenerationBytes()
globalThis.gc({ type: "minor", execution: "sync" })
promotedBytes += oldGenerationBytes() - before
}
}
await sleep(0)
const fullGcs = fullGcStarts.filter((time) => time > start).length
console.log(`${name}: ${(promotedBytes / 400 / 1024).toFixed(1)} KB promoted per update, ${fullGcs} full GCs in 20 s`)
}
const ref = Effect.runSync(Ref.make(makeValue()))
await measure("Ref", (value) => Effect.runSync(Ref.set(ref, value)))
const subscriptionRef = Effect.runSync(SubscriptionRef.make(makeValue()))
await measure("SubscriptionRef", (value) => Effect.runSync(SubscriptionRef.set(subscriptionRef, value)))
What is the expected behavior?
A SubscriptionRef promotes no more than a Ref. A value that has been replaced dies in the young generation.
What do you see instead?
Ref: 0.0 KB promoted per update, 0 full GCs in 20 s
SubscriptionRef: 74.5 KB promoted per update, 3 full GCs in 20 s
Every value is promoted. The old generation grows by the size of each value, and V8 runs full GCs that a Ref holding the same values never needs. An app that keeps large state in a SubscriptionRef and replaces it often pays for this with full-GC pauses.
Additional information
The other buffers in PubSub.ts already clear what they drop:
- the bounded implementations reset a slot to
AbsentValue UnboundedPubSubclears the value of each node once every subscriber has taken itReplayWindowImplclears what it hands out
ReplayBuffer is the only one that does not. Nothing reads a node after offer drops it. Since #6805, which fixed #6804, a different leak in this code, ReplayWindowImpl copies the values it needs. So offer can clear the node it drops:
if (this.size === this.capacity) {
- this.head = this.head.next!
+ const dropped = this.head
+ this.head = dropped.next!
+ dropped.next = null
+ dropped.value = AbsentValue
}
With that change the script prints SubscriptionRef: 0.0 KB promoted per update, 0 full GCs in 20 s. I have the fix with a test and a changeset and will open a PR.
A SubscriptionRef.changes subscriber that is waiting for the next value keeps values alive in a second, separate way, reported in #9497.
I investigated this with an AI coding agent, Claude Code. The agent read the Effect and V8 sources, wrote the repro, and ran it. I reviewed the analysis before filing.
- Dominant language
- TypeScript
- Stars
- 16.7k
- Forks
- 808
- Avg merge
- 10h 22m
- Merged PRs (30d)
- 771
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Effect-TS/effect
-
BrowserWorkerRunner: port finalizer throws when the worker global has no close() (Bun)Possibly taken @santiago-ramos-02 claimed this 19 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Effect-TS/effect#8635 · 3 comments ·
Maintainers usually reply within 1 day
-
Support {self: this} for fnUntracedMay be free again @ArjunCodess claimed this 30 days ago, and no pull request is open. Openenhancement
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Effect-TS/effect#8101 · 1 comment ·
Maintainers usually reply within 1 day
-
HashMap.set keeps the old value when the new one is Equal to itPossibly taken @schutzelaars claimed this today. Open
Difficulty 3/5 1-2 days Newbie friendliness 58/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 74/100
Maintainers usually reply within 1 day
-
OpenApi: optional query and header parameters are documented as nullablePossibly taken @Marve10s claimed this 2 days ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 57/100
Maintainers usually reply within 1 day
All issues in Effect-TS/effect
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
radix-ui/primitives#4228 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
SignalK/freeboard-sk#1093 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
[测试] 等待仓库加载后再记录多仓库排序基线Possibly taken @myot233 claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day