Repository navigation
fix(core): subscription leak — repository() registers a new usage stream on every call - #268
Merged
rvowles merged 2 commits intoSep 5, 2026
Conversation
EdgeFeatureHubConfig.repository() built a new UsageAdapter on every call, and each adapter registers a usage stream on the repository that nothing removes. Because newContext() calls repository(), an application building a context per request accumulated one usage stream per request for the life of the process. Every feature evaluation then fanned out over the whole accumulated list (recordUsageEvent -> _usageStreams.forEach), with UsageAdapter.process scheduling a microtask per plugin per event, so evaluation cost grew as O(contexts ever created). Track which repository the adapter was built for so it is created once per repository. If a caller replaces the repository via repository(repo), the previous adapter is closed, releasing its stream. repository() was a pure getter until 5c4cee5 ("Version 2.0 of SDK", featurehub-io#226), which added usage tracking and placed the adapter construction inside it.
🦋 Changeset detectedLatest commit: 1f0d07b The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
Author
|
@rvowles , really appreciate it if you could have a look at this at the earliest, as it's causing a significantly degraded user experience for our customers in production. |
rvowles
approved these changes
Sep 5, 2026
rvowles
left a comment
Contributor
There was a problem hiding this comment.
Confirmed, the usage adapter is getting recreated on every new context.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
EdgeFeatureHubConfig.repository()builds a newUsageAdapteron every call, and each adapter registers a usage stream on the repository that nothing ever removes. BecausenewContext()callsrepository()internally, an application that builds a context per request accumulates one usage stream per request for the life of the process.Every feature evaluation then fans out over the whole accumulated list (
recordUsageEvent→_usageStreams.forEach), withUsageAdapter.processscheduling a microtask per plugin per event. Evaluation cost is therefore O(contexts ever created): per-request cost grows linearly with requests served, and cumulative work is quadratic.Affects 2.0.0, 2.0.1 and 2.0.2. Introduced in 5c4cee5 ("Version 2.0 of SDK", #226), which added usage tracking and placed the adapter construction inside what had been a pure getter.
1. What this did to us in production
A Next.js SSR storefront on Node 24 using
featurehub-javascript-node-sdk2.0.1, resolving ~17 flags per request through a context built per request.Pods got slower for every hour they stayed alive. Pods serving identical request counts in the same hour, differing only in age:
A three-hour-old pod burned 3.7× the CPU of a one-hour-old one for the same work, with 19× the p95.
Once a pod passed roughly one core of JS work the event loop stopped keeping up and every route stalled together — including a static health endpoint that does no work at all, so Kubernetes liveness probes began failing on processes that were perfectly healthy. CPU throttling was zero throughout; the single JS thread was the limit.
Reproduced locally on the same container image at constant load: CPU per request rose from 121 ms to 214 ms in 20 minutes, with
_usageStreams.sizetracking the request count exactly (851 requests → 934 streams). CPU profiles attributed 34 % of busy CPU to the SDK on a degraded process versus 1.9 % on a freshly started one, almost all of it inside the_usageStreams.forEachcallback.It was hard to find precisely because it looks like nothing: no error, no memory growth worth noticing, and a fresh process profiles completely clean.
2. Reproducing it in your own example
examples/todo-backend-typescript, unmodified. No FeatureHub server or API key needed — local YAML mode is enough.Seed ~10 todos, then time
GET /todo/:userrepeatedly. Nothing instrumented, no SDK internals touched — just HTTP requests and a stopwatch:Restart the server and it starts from the beginning again, climbing identically. With this PR applied the same example stays flat.
The example is affected because
getTodoscallsctx()per request, which doesfhConfig.newContext(...).build(), andprocessTitlethen evaluates several features per todo — one leaked stream per request, ~50 evaluations per request, each fanning out over every stream leaked so far.Note that
newContext(repository, edgeService)doesrepository = repository || this.repository(), so passingnullexplicitly — as the example does — does not avoid it.3. Six lines that show it directly
No server, no API key, no example app — the SDK's own no-op mode:
4. The fix
Create the usage adapter once per repository instead of once per call.
This PR tracks which repository the adapter was built for. If a caller replaces the repository through
repository(repo), the previous adapter is closed, which releases its stream — nothing in the repo does that today, but it is public API, and without it the adapter would keep listening to a discarded repository.Usage tracking is unaffected
Since the change removes adapters, I checked the feature still behaves identically. The same script was run against patched and unpatched builds, recording every event a registered
UsagePluginreceives, across three registration orderings — plugin registered after the repository, before it exists (the ordering your example uses), and two plugins registered either side of a context. Output was byte-for-byte identical: samereadyness,featureand named-collection events, sameuserKey, same records.Test plan
Three new tests in
packages/core/src/__tests__/usage_adapter_reuse.test.ts. All three fail without the source change —expected 101 to be 1— verified by reverting it and re-running.pnpm run lint— cleanpnpm run typecheck— cleanexamples/todo-backend-typescript— flat over 3,000 requestsTwo questions for you
_usageStreamsmap through a narrow cast rather than widening the public API. Happy to add ausageStreamCountaccessor toClientFeatureRepositoryinstead if you'd prefer that.close()on any plugins registered throughaddUsagePlugin(). That seemed right for an adapter being torn down, but if you'd rather carry plugins across a repository swap, that's a small change.Unrelated, noticed while tracing this
Happy to raise these separately if useful:
FeatureStateBaseHolder.untrackedValueisinternalGetValue(true)— identical tovalue— so it records usage despite its name. It matters here because it's the only obvious way to read a feature without generating a usage event.repository().simpleFeatures()is documented as the way to "get a list of all feature keys", but it materialises every value through the trackedflag/str/num/rawJsongetters, firing a usage event per feature, just to return keys.featureKeys/serverProvidedFeatureKeysdo the same job without side effects.