Description
We're building a generative sleep-soundscape app (UIBackgroundModes: audio, plugin iosBackgroundMode: true) that plays continuously for 8+ hours with the screen locked. Across multiple overnight device tests the app was repeatedly killed by jetsam with a footprint of ~1.9–2.75GB (kill reason proc-thrashing on the final kills; state active since the audio session keeps it scheduled). Steady-state audio memory for our loaded kit is well under 500MB, so ~2GB+ of that is leaked.
We progressively rearchitected the app to isolate causes, and found two distinct native memory leaks plus one verified AudioParam automation bug (we initially suspected two others — stale .value reads and stacked-setTargetAtTime glitches — and ruled both out by direct measurement; details in item 3, since the ruled-out hypotheses may save you triage time). Filing together because they came from one investigation; happy to split into separate issues if you prefer.
1. Native memory behind a dropped AudioBuffer is only freed by Hermes GC finalizers — an idle background app never runs GC, so every mid-session decode leaks until jetsam
Our original design decoded bed stems just-in-time and dropped the JS reference when a stem rotated out. Each decode+drop cycle grew the footprint by ~23MB (the decoded PCM size), and that memory was never reclaimed while the app idled in the background — Hermes GC simply doesn't run when the JS thread is nearly idle, so the finalizers that release the native buffer never fire. The footprint climbed monotonically all night until jetsam at ~2.6GB.
Evidence that this is GC-dependence rather than an app-side retain: in the foreground (simulator and device with normal UI/JS activity driving GC), the identical code produces a sawtooth — the buffers are eventually freed; only the idle-background case climbs monotonically. (We also added a periodic globalThis.gc?.() call as a defensive measure, but could not confirm it is exposed in release Hermes builds, so we can't say whether an explicit GC would have reclaimed the memory.)
This may be "working as designed" from the binding's point of view, but it makes any decode-and-release usage pattern fatal in a background-audio app — precisely the kind of app the iosBackgroundMode plugin option targets. Related: #822 asked how to deterministically release decoded buffers and the answer was to rely on GC; this issue is evidence that relying on GC is not viable in background apps. An explicit release/close() API on AudioBuffer (or freeing the native allocation eagerly when the last source using it ends, or registering the native allocation size with Hermes so GC feels the memory pressure, e.g. a NativeMemoryPressure/external-memory hint) would all solve it.
Workaround we shipped: decode the whole session's stem set up front and never decode mid-session (fixed footprint), plus a periodic globalThis.gc?.() nudge.
2. With zero mid-session decodes, a steady ~10–14MB/min native leak remains, proportional to JS↔native call rate — not to audio played
After eliminating all mid-session decodes (fixed buffer set, fixed node graph for beds), overnight tests still showed a steady ~10–14MB/min climb (~5GB/8h if jetsam didn't intervene first).
Across successive overnight tests, the rate tracked our JS↔native call rate, not audio activity (correlational across builds rather than an isolated A/B, but consistent over three nights):
- Baseline: scheduler wrote
AudioParam.value (gain walks on ~6 params) every 200ms and read AudioContext.currentTime several times per tick → ~14MB/min.
- Making audio events 4× rarer (same tick rate) left the rate essentially unchanged (~10MB/min) — the leak does not follow sounds played.
- Halving the tick rate and replacing per-tick param writes with one-shot automation (
setValueAtTime + linearRampToValueAtTime armed up front) roughly halved it (~6MB/min).
- Reducing steady-state native calls to near zero (pre-armed ramps only, 5s tick, no per-tick param access) let the app run 10+ hours where it previously died at 4–6 — we did not capture the residual growth rate, but it dropped below the ~4MB/min that would have killed it.
This looks like a small per-call allocation on the JSI boundary (or per-call scheduling objects on the audio thread) that is never released, or that again waits on JS GC that never runs (see item 1 — the two may share a root cause). ~10MB/min ÷ ~50 calls/sec ≈ 3–4KB per call.
3. cancelScheduledValues does not cancel in-flight automation — cancelled ramps keep running as "zombies", then cause discontinuous jumps when they expire (verified, captured data below)
Per spec, cancelScheduledValues(t) removes scheduled events with time ≥ t. On 0.13.3 iOS it appears to be a no-op for an in-flight linearRampToValueAtTime: after cancelling mid-ramp, .value continues along the cancelled ramp's trajectory, and even a subsequent setValueAtTime doesn't displace it. When the zombie ramp finally reaches its original end time, the param snaps discontinuously to whatever the surviving events dictate.
Captured on-device (probe code in "Steps to reproduce"): ramp 1→0 over 10s; at t+5 (value = 0.500) we call cancelScheduledValues(now) — subsequent reads: 0.449, 0.398, 0.347, 0.297, 0.245 — precisely the cancelled ramp's slope. A following experiment then called cancelScheduledValues again plus setValueAtTime(0, now) and setTargetAtTime(0.8, now, 1.0): .value still tracked the old zombie ramp down to 0.01, then at the zombie's original end time jumped discontinuously from 0.010 → 0.702 in one 250ms sample as the setTargetAtTime (by then 2s into convergence) took over.
Practical consequence: the standard retargeting idiom — cancelScheduledValues(t) + setValueAtTime(current, t) + linearRampToValueAtTime(next, t + d) — silently stacks conflicting automation instead of replacing it. In our app (periodic gain "walks" + crossfades on the same params), zombie expirations produced audible pops and fades that appeared to restart — a rain bed cutting in and out was the user-facing symptom. Workaround: minimize retargeting frequency and prefer one-shot pre-armed ramps (which also mitigates leak item 2).
Two hypotheses we ruled out with the same probe, to save triage time: (a) .value mid-ramp reads are correct on 0.13.3 — sampling an undisturbed 10s linear ramp returned the spec-computed values (0.90, 0.80, … 0) every second; (b) stacking a second setTargetAtTime onto a converging one transitions smoothly in isolation — the discontinuity we originally attributed to stacking (family of #1191/#1025/#1207) is fully explained by zombie-ramp expiry from the broken cancel.
Steps to reproduce
Minimal sketches per item (all iOS device, Hermes; leaks need the release build backgrounded with the screen locked and footprint watched via Xcode Organizer/Instruments or jetsam logs):
Item 3 — easiest, reproduces in the foreground in seconds (we ran exactly this and captured the output below):
import { AudioContext } from 'react-native-audio-api';
const ctx = new AudioContext(); // ours: { sampleRate: 32000 }, state "running"
const osc = ctx.createOscillator();
const test = ctx.createGain();
const mute = ctx.createGain();
mute.gain.value = 0; // keep the probe path rendered but inaudible
osc.connect(test); test.connect(mute); mute.connect(ctx.destination);
osc.start();
const t0 = ctx.currentTime;
test.gain.setValueAtTime(1, t0);
test.gain.linearRampToValueAtTime(0, t0 + 10);
// ...wait 5s...
// value here: 0.500 (computed — correct)
test.gain.cancelScheduledValues(ctx.currentTime);
// value immediately after cancel: 0.500, THEN (sampled every 500ms):
// 0.449, 0.398, 0.347, 0.297, 0.245 ← the CANCELLED ramp keeps running
Captured continuation (same run): after another cancelScheduledValues +
setValueAtTime(0) + setTargetAtTime(0.8, now, 1.0), .value still tracked
the zombie ramp (0.194 → 0.010 over 1.8s), then at the zombie's original end
time jumped 0.010 → 0.702 between two 250ms samples — the audible pop.
Control captures from the same probe (both correct, ruling out our two earlier
hypotheses): an undisturbed ramp reads 1.00 / 0.90 / 0.80 / … / 0 at 1s
intervals, and a second setTargetAtTime stacked at +4s onto a converging one
transitions smoothly (0.784 → 0.689 → 0.574 → … toward 0.2, no discontinuity).
Item 1 — decode-and-drop churn:
const ctx = new AudioContext();
// any local asset that decodes to ~20MB+ of PCM (e.g. a 2-min stereo 48kHz file)
setInterval(async () => {
const buf = await ctx.decodeAudioData(FILE_PATH);
console.log('decoded', buf.duration); // buf dropped here — only reference gone
}, 30_000);
// Background the app with an active audio session (play anything) and lock the screen.
// Footprint grows ~[decoded size] per interval and is never reclaimed → jetsam.
// Foregrounded, the same loop sawtooths (GC runs). globalThis.gc() frees it instantly.
Item 2 — param writes + clock reads at a steady rate, no decodes, no new nodes:
const ctx = new AudioContext();
const g = ctx.createGain();
g.connect(ctx.destination);
// keep an audio session active (e.g. one looping silent source), then:
setInterval(() => {
const t = ctx.currentTime; // native read
g.gain.value = 0.5 + 0.1 * Math.sin(t); // native write
}, 200);
// Overnight in background: steady ~10–14MB/min RSS growth in our tests (6 params + a few
// extra reads per tick). Growth scales with the interval rate; audio output is unchanged.
// Replacing the interval with one linearRampToValueAtTime flattens it.
Evidence from overnight runs: 16 JetsamEvent-*.ips logs across 2026-08-29 → 2026-08-31 with our app as largestProcess at 1.9–2.75GB footprint (pageSize 16384 × rpages 113k–168k), kill reason proc-thrashing, state active (background audio). Can attach the .ips files on request.
Snack or a link to a repository
No standalone repro repo yet — the item-3 probe above is complete and drop-in (we ran it verbatim; the quoted numbers are its captured output). Happy to put together a minimal repro repo for any of the items on request, and can attach the JetsamEvent .ips files.
React Native Audio API version
0.13.3
React Native version
0.86.2 (Expo SDK 57 / expo ~57.0.16)
Platforms
iOS
JS engine
Hermes
Device / OS
iPad 10th generation (4GB RAM), iOS 26.6.1, physical device, release build, UIBackgroundModes: audio, screen locked during tests
Acknowledgements
Yes — I searched existing issues (closest: #822, #400, #1191, #1025, #1011; none cover these on 0.13.3)
Description
We're building a generative sleep-soundscape app (
UIBackgroundModes: audio, pluginiosBackgroundMode: true) that plays continuously for 8+ hours with the screen locked. Across multiple overnight device tests the app was repeatedly killed by jetsam with a footprint of ~1.9–2.75GB (kill reasonproc-thrashingon the final kills; stateactivesince the audio session keeps it scheduled). Steady-state audio memory for our loaded kit is well under 500MB, so ~2GB+ of that is leaked.We progressively rearchitected the app to isolate causes, and found two distinct native memory leaks plus one verified
AudioParamautomation bug (we initially suspected two others — stale.valuereads and stacked-setTargetAtTimeglitches — and ruled both out by direct measurement; details in item 3, since the ruled-out hypotheses may save you triage time). Filing together because they came from one investigation; happy to split into separate issues if you prefer.1. Native memory behind a dropped
AudioBufferis only freed by Hermes GC finalizers — an idle background app never runs GC, so every mid-session decode leaks until jetsamOur original design decoded bed stems just-in-time and dropped the JS reference when a stem rotated out. Each decode+drop cycle grew the footprint by ~23MB (the decoded PCM size), and that memory was never reclaimed while the app idled in the background — Hermes GC simply doesn't run when the JS thread is nearly idle, so the finalizers that release the native buffer never fire. The footprint climbed monotonically all night until jetsam at ~2.6GB.
Evidence that this is GC-dependence rather than an app-side retain: in the foreground (simulator and device with normal UI/JS activity driving GC), the identical code produces a sawtooth — the buffers are eventually freed; only the idle-background case climbs monotonically. (We also added a periodic
globalThis.gc?.()call as a defensive measure, but could not confirm it is exposed in release Hermes builds, so we can't say whether an explicit GC would have reclaimed the memory.)This may be "working as designed" from the binding's point of view, but it makes any decode-and-release usage pattern fatal in a background-audio app — precisely the kind of app the
iosBackgroundModeplugin option targets. Related: #822 asked how to deterministically release decoded buffers and the answer was to rely on GC; this issue is evidence that relying on GC is not viable in background apps. An explicit release/close()API onAudioBuffer(or freeing the native allocation eagerly when the last source using it ends, or registering the native allocation size with Hermes so GC feels the memory pressure, e.g. aNativeMemoryPressure/external-memory hint) would all solve it.Workaround we shipped: decode the whole session's stem set up front and never decode mid-session (fixed footprint), plus a periodic
globalThis.gc?.()nudge.2. With zero mid-session decodes, a steady ~10–14MB/min native leak remains, proportional to JS↔native call rate — not to audio played
After eliminating all mid-session decodes (fixed buffer set, fixed node graph for beds), overnight tests still showed a steady ~10–14MB/min climb (~5GB/8h if jetsam didn't intervene first).
Across successive overnight tests, the rate tracked our JS↔native call rate, not audio activity (correlational across builds rather than an isolated A/B, but consistent over three nights):
AudioParam.value(gain walks on ~6 params) every 200ms and readAudioContext.currentTimeseveral times per tick → ~14MB/min.setValueAtTime+linearRampToValueAtTimearmed up front) roughly halved it (~6MB/min).This looks like a small per-call allocation on the JSI boundary (or per-call scheduling objects on the audio thread) that is never released, or that again waits on JS GC that never runs (see item 1 — the two may share a root cause). ~10MB/min ÷ ~50 calls/sec ≈ 3–4KB per call.
3.
cancelScheduledValuesdoes not cancel in-flight automation — cancelled ramps keep running as "zombies", then cause discontinuous jumps when they expire (verified, captured data below)Per spec,
cancelScheduledValues(t)removes scheduled events with time ≥ t. On 0.13.3 iOS it appears to be a no-op for an in-flightlinearRampToValueAtTime: after cancelling mid-ramp,.valuecontinues along the cancelled ramp's trajectory, and even a subsequentsetValueAtTimedoesn't displace it. When the zombie ramp finally reaches its original end time, the param snaps discontinuously to whatever the surviving events dictate.Captured on-device (probe code in "Steps to reproduce"): ramp 1→0 over 10s; at t+5 (
value= 0.500) we callcancelScheduledValues(now)— subsequent reads: 0.449, 0.398, 0.347, 0.297, 0.245 — precisely the cancelled ramp's slope. A following experiment then calledcancelScheduledValuesagain plussetValueAtTime(0, now)andsetTargetAtTime(0.8, now, 1.0):.valuestill tracked the old zombie ramp down to 0.01, then at the zombie's original end time jumped discontinuously from 0.010 → 0.702 in one 250ms sample as thesetTargetAtTime(by then 2s into convergence) took over.Practical consequence: the standard retargeting idiom —
cancelScheduledValues(t)+setValueAtTime(current, t)+linearRampToValueAtTime(next, t + d)— silently stacks conflicting automation instead of replacing it. In our app (periodic gain "walks" + crossfades on the same params), zombie expirations produced audible pops and fades that appeared to restart — a rain bed cutting in and out was the user-facing symptom. Workaround: minimize retargeting frequency and prefer one-shot pre-armed ramps (which also mitigates leak item 2).Two hypotheses we ruled out with the same probe, to save triage time: (a)
.valuemid-ramp reads are correct on 0.13.3 — sampling an undisturbed 10s linear ramp returned the spec-computed values (0.90, 0.80, … 0) every second; (b) stacking a secondsetTargetAtTimeonto a converging one transitions smoothly in isolation — the discontinuity we originally attributed to stacking (family of #1191/#1025/#1207) is fully explained by zombie-ramp expiry from the broken cancel.Steps to reproduce
Minimal sketches per item (all iOS device, Hermes; leaks need the release build backgrounded with the screen locked and footprint watched via Xcode Organizer/Instruments or jetsam logs):
Item 3 — easiest, reproduces in the foreground in seconds (we ran exactly this and captured the output below):
Captured continuation (same run): after another
cancelScheduledValues+setValueAtTime(0)+setTargetAtTime(0.8, now, 1.0),.valuestill trackedthe zombie ramp (0.194 → 0.010 over 1.8s), then at the zombie's original end
time jumped 0.010 → 0.702 between two 250ms samples — the audible pop.
Control captures from the same probe (both correct, ruling out our two earlier
hypotheses): an undisturbed ramp reads 1.00 / 0.90 / 0.80 / … / 0 at 1s
intervals, and a second
setTargetAtTimestacked at +4s onto a converging onetransitions smoothly (0.784 → 0.689 → 0.574 → … toward 0.2, no discontinuity).
Item 1 — decode-and-drop churn:
Item 2 — param writes + clock reads at a steady rate, no decodes, no new nodes:
Evidence from overnight runs: 16
JetsamEvent-*.ipslogs across 2026-08-29 → 2026-08-31 with our app aslargestProcessat 1.9–2.75GB footprint (pageSize16384 ×rpages113k–168k), kill reasonproc-thrashing, stateactive(background audio). Can attach the .ips files on request.Snack or a link to a repository
No standalone repro repo yet — the item-3 probe above is complete and drop-in (we ran it verbatim; the quoted numbers are its captured output). Happy to put together a minimal repro repo for any of the items on request, and can attach the JetsamEvent .ips files.
React Native Audio API version
0.13.3
React Native version
0.86.2 (Expo SDK 57 / expo ~57.0.16)
Platforms
iOS
JS engine
Hermes
Device / OS
iPad 10th generation (4GB RAM), iOS 26.6.1, physical device, release build,
UIBackgroundModes: audio, screen locked during testsAcknowledgements
Yes — I searched existing issues (closest: #822, #400, #1191, #1025, #1011; none cover these on 0.13.3)