Repository navigation
CI: test/firestore.test.tsx flakes intermittently on both Node 22 and Node 24 #776
Description
test/firestore.test.tsx fails intermittently in CI and passes on re-run with no code change. It has now fired five times in three days, every time on a PR that cannot plausibly cause it, including a lockfile-only dependency bump and a README-only docs change.
Every occurrence costs a full CI cycle and a manual re-run, and it trains reviewers to ignore red runs.
⚠️ Two corrections, 2026-08-05This issue previously said the flake was Node 24 only and that the
RESOURCE_EXHAUSTEDerror was ruled out. Both were wrong, and the earlier investigation comment is superseded on those two points.
- It is not Node-24-specific. On docs: replace the concurrent mode note with a Suspense section #778, a README-only diff,
Test Node.js 22failed twice consecutively whileTest Node.js 24passed in the same runs. The earlier "Node 22 passes every time" claim rested on a local sample of 6 runs, which was underpowered rather than clean.RESOURCE_EXHAUSTEDis not ruled out. It is absent from every local reproduction, which is all the evidence supported. In CI it is present, including on the docs: replace the concurrent mode note with a Suspense section #778 failures. This raises the possibility that the local repro and the CI failure are not the same bug, which matters because the fix below was measured against the local one.
Signature
AssertionError: expected 'loading' to deeply equal 'success'
Expected: "success"
Received: "loading"
In CI, alongside it on the same run:
@firebase/firestore: Firestore (11.10.0): GrpcConnection RPC 'Listen' stream error.
Code: 8 Message: 8 RESOURCE_EXHAUSTED: Received message larger than max (795107700 vs 4194304)
A ~795 MB (and in an earlier run ~3.5 GB) claimed frame against a 4 MB cap is a gRPC framing desync: the reader is interpreting payload bytes as a length header. That is a @grpc/grpc-js-layer failure, not an application one.
Locally the failure looks different: waitFor hitting its 1000ms default, at 1058ms and 1142ms, plus one 120s hang, with no gRPC error at all.
Occurrences
| Date | PR | What it touched | Which Node |
|---|---|---|---|
| 2026-08-05 | #778 | README.md only |
22 (twice in a row; 24 passed) |
| 2026-08-04 | #774 | package-lock.json only (postcss bump) |
24 |
| 2026-08-03 | #770 | src/ import specifiers only |
24 |
| 2026-08-03 | (earlier run) | no source changes | 24 |
The two tests that fail
Both are in test/firestore.test.tsx, and the same two every time:
useFirestoreDocData > returns undefined if document does not exist(~line 100)useFirestoreDocOnce > works when the document does not exist, and does not update when it is created(~line 174)
They are the two tests that read a document that does not exist. That is the shared property, and it is more specific than "these tests are slow". A non-existent document cannot be answered from the local cache, so the hook stays in loading until the Listen stream confirms absence. That round trip is what exceeds waitFor's 1000ms budget, so these two fail first whenever the stream is slow or broken.
Suspected cause
The current suspect is @grpc/grpc-js 1.9.16, which @firebase/firestore pins via ~1.9.0. A framing desync is exactly the class of bug that lives at that layer, which is what the CI-side RESOURCE_EXHAUSTED points at.
Measured locally, on Node 24:
| Arm | Failures |
|---|---|
Baseline (@grpc/grpc-js 1.9.16) |
6 / 44 (13.6%) |
overrides forcing 1.14.4 |
0 / 38 |
This is suggestive, not proven, and the caveats have grown:
- The pre-registered 30-runs-per-arm comparison came back p = 0.12, which is not significant. Pooling all batches gives p = 0.02, but that pooling was chosen after seeing the results.
- The measurement was against the local failure mode, which has no gRPC error. If the CI failure is a different bug, these numbers do not speak to it.
- The Node 22 result that made Node 24 look special has since been contradicted in CI.
Two constraints on any fix:
- Upgrading
firebasedoes not help. Every published@firebase/firestorethrough 4.17.0 still pins~1.9.0. Anoverridesentry is the only lever in this repo. - An override makes CI test a
@grpc/grpc-jsthat consumers do not get. If 1.9.16 is genuinely unwell, the bug still reaches anyone running reactfire server-side, and the real fix belongs upstream in firebase-js-sdk. An override here buys a quiet CI, not a fixed product.
Suggested scope
Apply the overrides bump as an uncontrolled fix, and say so plainly rather than claiming it is verified.
- flake stops → consistent with the theory, but not proof
- flake continues → the theory is wrong, and that is genuinely informative
If it continues, the 120s hang is the next thread. Note that raising the waitFor timeout would not have saved that run, so "just bump the timeout" is not a sufficient fallback.
Note for reviewers
Until this is fixed: a red Test Node.js 22 or Test Node.js 24 on a PR touching no source is probably this. Re-run before investigating, and say so in the PR thread rather than leaving a red run unexplained. Do not conclude anything from a single run in either direction, since one pass does not clear a branch and one failure does not condemn it. As of #778, assume it may take more than one re-run.
Activity
tyler-reitz commentedon Aug 4, 2026
Reproduced this locally and ran a controlled comparison. Two results: the
RESOURCE_EXHAUSTEDlead in the description is a dead end, and the@grpc/grpc-jsversion is a strong but not conclusive suspect.Correction to the description
The
Received message larger than maxerror appeared in none of the local failures. The actual signature iswaitForhitting its 1000ms default, failing at 1058ms and 1142ms, plus one run where the test hung for 120s.So the 3.5 GB frame co-occurs with the flake rather than causing it. Anyone picking this up should not start from the "corrupted length prefix" theory, which is what the description currently points at.
What was measured
Each run is the full firestore suite against the emulator. All Node 24 runs on the same machine, same suite, back to back.
@grpc/grpc-js1.9.16 (what we ship)@grpc/grpc-js1.9.16@grpc/grpc-js1.14.4 (forced viaoverrides)Two additional failures were excluded as unrelated infrastructure noise (emulator startup crashes, distinct exit codes and no test assertion).
How strong is this
Weaker than the table suggests, and worth stating plainly:
The correlation is real and the mechanism is plausible, since 1.9.16 predates Node 24 by a wide margin. It is not proven, and it should not be repeated as settled.
The constraint on any fix
@firebase/firestorepins@grpc/grpc-jsat~1.9.0. Every published version through the current 4.17.0 still does, so this cannot be fixed by upgrading thefirebasedependency. Anoverridesentry is the only lever available in this repo.That has a cost worth naming: an override here means CI tests a
@grpc/grpc-jsthat consumers do not get. If 1.9.16 really is unwell on Node 24, the bug still reaches anyone running reactfire server-side on Node 24, and the real fix belongs in firebase-js-sdk.Suggested next step
Try the override and let CI accumulate evidence, rather than spending more local runs chasing significance. If the flake stops across the next few weeks of PRs, that is a stronger signal than any batch run here. If it does not, the grpc-js theory is wrong and the 120s hang is the thread to pull next, since a raised
waitFortimeout would not have saved that run.[-]CI: test/firestore.test.tsx flakes on Node 24 with RESOURCE_EXHAUSTED on the Listen stream[/-][+]CI: test/firestore.test.tsx flakes intermittently on both Node 22 and Node 24[/+]ci: address review feedback on the firestore flake probe
ci: add a manual firestore flake probe for #776 (#780)
tyler-reitz commentedon Aug 7, 2026
First controlled measurement in CI: 120 runs, zero failures
The probe from #780 is on
mainand ran today at its defaults, 30 iterations per arm, both arms sequentially on one runner: run 31204989659.Read this as a negative result, not as good news. Three things follow.
0.88^60, about 0.05%. By the rule of three the true rate for this configuration is under 5% at 95% confidence.@grpc/grpc-jsbump. The comparison this issue wants has not been made yet.RESOURCE_EXHAUSTEDin 120 CI runs, which is the part that changes the diagnosis below.The 08-05 correction was right to fire, but "local vs CI" is the wrong axis
That correction framed the discriminator as environment: absent locally, present in CI. These 120 runs were in CI, on CI hardware, and the gRPC error did not appear once.
So the variable is the workload, not the environment. The failing runs are full
npm run test: five emulators, the whole suite, parallel vitest workers. The probe is one emulator and one file. Everything that distinguishes a failing CI run from a passing probe run is on that axis: emulator contention, concurrent streams, total memory and CPU pressure.That is a more useful place to look than the client library in isolation, and it also means the local repro and the CI failure being "different bugs" may be understating it: the isolated firestore suite may simply not be capable of producing either one at a measurable rate.
New occurrence, and a diagnostic worth having
Add to the occurrence table:
example/comments onlySame two tests, same assertion, alongside
RESOURCE_EXHAUSTED: Received message larger than max (1633837427 vs 4194304). It cleared on a single re-run today, so the "#778 needed two" pattern has not repeated.The bogus frame lengths are ASCII, and that is direct evidence for the framing-desync reading. Decoding the two exact values on record as big-endian bytes:
0x2f646174/dat0x61626173abasBoth are printable, and both are 4-byte windows into the same string:
/databases, at offset 0 and offset 4. That is the Firestore resource path,projects/<project>/databases/(default)/documents.So the reader is not producing a garbage number from corrupted binary. It is reading four bytes of the resource path out of the message body and using them as the length prefix. That upgrades the framing-desync diagnosis from an inference about implausible sizes to something directly observed, and it means the desync offset varies between runs rather than being a fixed misalignment.
What this means for the suggested scope
The proposal above is to apply the
overridesbump as an uncontrolled fix and say so plainly. That still holds, and the honest reading of the aftermath is unchanged. But one caveat gets sharper: we now know the isolated suite cannot serve as a control at all, so merging the override forecloses the comparison entirely rather than merely making it harder.Before doing that, the cheaper move is to widen the probe to run the actual CI workload. That is tracked in #783 along with the timeout arithmetic it forces (a full-suite iteration is roughly 57s against 8s here, so the job timeout has to rise, and the arms must stay on one runner).
tyler-reitz commentedon Aug 7, 2026
Correction to the timing figure in my comment above
That comment closed by saying a full-suite probe iteration is "roughly 57s against 8s here, so the job timeout has to rise."
The 57s was the whole
Test Node.js 22job, including checkout,npm ci, Java setup, functions install and artifact download. TheRun testsstep alone is 23s. Corrected figures:Both fit inside the existing 60 minute job timeout, so widening the probe to the real CI workload is cheaper than I said. Nothing else in that comment changes: the 120/120 clean result, the exclusion of the 13.6% rate for the isolated suite, the workload-not-environment reframing, and the ASCII decoding of the bogus frame lengths all stand.
The widened probe is now open as #785.
One finding from building it that belongs on this issue, because it affects how any future measurement should be read:
test/auth.test.tsx,test/firestore.test.tsxandtest/database.test.tsxeach open with adouble check - emulator is runninghealth check, and those fail by timing out. So an emulator that never came up looks, in a log, exactly like a test hanging. Anyone triaging a red CI run here should check whether the health check failed before concluding anything about a hang, and that includes the 120s hang recorded in this issue.tyler-reitz commentedon Aug 7, 2026
New occurrence, and it is the most informative one yet
#785, which adds one
workflow_dispatch-only workflow file and touches nothingtest.yamlruns. Both test jobs failed; both passed on re-run with no code change.workflow_dispatch-only workflow fileSame two tests as always,
useFirestoreDocData > returns undefined if document does not existanduseFirestoreDocOnce > works when the document does not exist, and does not update when it is created, withRESOURCE_EXHAUSTED: Received message larger than max (1650553701 vs 4194304).This is the first time both Node versions failed in the same run. The previous worst case was #778, where Node 22 failed twice consecutively while Node 24 passed. Two versions failing simultaneously on the same run is evidence against anything Node-specific, and points at conditions shared by the whole run rather than by an interpreter version. That lines up with today's probe result, where the isolated firestore suite came back 120/120 clean and the distinguishing variable turned out to be the workload rather than the environment.
The frame lengths are now three for three
Adding today's value to the two decoded earlier:
/databases/0x2f646174/dat0x61626173abas0x62617365baseThree independent samples, every one printable ASCII, every one a four-byte window into the same string, at three different offsets.
Two things follow that a single sample could not establish:
projects/<project>/databases/(default)/documents) and using them as a length prefix.The practical consequence for the fix: the size in the error message is meaningless as a diagnostic.
795107700and1650553701are not "a huge message", they are text. Nobody should chase the magnitude.ci(flake-probe): fix the errexit abort and scope counting to firestore
ci(flake-probe): add a full-suite workload mode (#785)
tyler-reitz commentedon Aug 7, 2026
The full suite reproduces it, and the
@grpc/grpc-jsoverride does not fix itRun 31223456796, full-suite mode, 30 iterations per arm, both Node versions, both arms sequential on one runner. 120 runs total.
RESOURCE_EXHAUSTEDZero infra failures, zero hangs and zero unrecognized failures across all 120 runs, so nothing was silently miscounted in either direction.
1. It is the workload, not the environment
9 flakes in 120 full-suite runs (7.5%, 95% CI 4.0 to 13.6). The firestore-only mode returned 0 in 120 earlier the same day, on the same hardware.
That settles the question the 2026-08-05 correction left open. It framed the discriminator as local versus CI, because the gRPC error appeared only in CI. The isolated suite running in CI produces neither the flake nor the gRPC error, so the variable is five emulators and a parallel suite versus one emulator and one file: contention, concurrent streams, resource pressure.
2.
RESOURCE_EXHAUSTEDonly exists under the real workload31 of 120 runs (25.8%), against zero in any firestore-only run ever recorded, local or CI. The signal that motivated this whole investigation is invisible unless the full suite is running.
3. Every flake co-occurred with the gRPC desync, but most desyncs are harmless
The overlap column equals the flake count in all four arms: 3/3, 1/1, 2/2, 3/3. All 9 flakes had
RESOURCE_EXHAUSTEDin the same run.The converse does not hold, and it is the more interesting half: 31 desyncs produced only 9 flakes. So the desync looks necessary but is clearly not sufficient. Roughly two thirds of the time the reader resynchronizes without a test noticing.
4.⚠️ The override does not fix the flake
RESOURCE_EXHAUSTEDFisher exact, two-sided: p = 1.0 on the flake counts and p = 0.40 on
RESOURCE_EXHAUSTED. No detectable effect on either.The sharp version. The override was proposed on a local 0 out of 38. If it eliminated the flake, 60 override runs should have produced approximately zero. They produced four, with a 95% CI of 2.6% to 15.9% that comfortably excludes zero. P(0 flakes in 60 | the observed 7.5% rate) is about 0.9%. So the local result does not replicate, and "the override fixes it" is ruled out.
What follows
addDoc, took 2245ms in docs(example): drop the concurrent mode framing from the demo entry #781's failing run while everywaitForin that file uses testing-library's 1000ms default. The two tests that fail are the two that read a document that does not exist, which cannot be served from cache and must complete a server round trip. That is consistent with the local failures at 1058ms and 1142ms. The next probe change is to record the firestore health check duration per iteration and test whether slow rounds correlate with flakes.Reproducing this:⚠️ Note that any table produced before #785 merged is unreliable: the probe aborted on the first failing iteration, so it could only ever report a clean run.
workflow_dispatchonFirestore flake probe, defaults. Roughly 22 minutes per job.ci(probe): record emulator health-check durations per iteration
tyler-reitz commentedon Aug 10, 2026
Correcting something I posted above: the frame lengths are not ASCII. That was five hand-decoded samples, and they happened to be the printable ones.
I decoded all 31 occurrences from the probe run's artifacts. 25 distinct values, of which 5 are fully printable (
abas,atab,data,good,ses/). The other 20 are binary, several with the shape of protobuf varints.What holds up: 20% printable against a 1.9% chance baseline, so text is over-represented roughly tenfold, and the reader is taking four bytes from inside the message body.
What doesn't: 25 distinct values across 31 events means the resync point is essentially arbitrary. The "slips by exactly one 5-byte gRPC header" reading was an artifact of looking only at the printable ones.
So this thread doesn't discriminate between a grpc-js bug, a Node http2 bug, and the emulator writing a bad frame. Worth flagging that the emulator side has never been tested, and the override experiment couldn't have tested it. The timing lead looks like the better thread, and #787 adds per-iteration health-check durations so we can check it against real data.
ci(probe): record emulator health-check durations per iteration
armando-navarro commentedon Aug 11, 2026
Following the note at the end of your last comment, that the next probe change is to record the firestore health check per iteration and see whether slow rounds correlate with flakes. I went looking for that data and it turns out most of it is already sitting in run 31223456796's artifacts.
The durations are recoverable from the run you already did
Vitest's default reporter prints a per-test line in two cases: any test slower than
slowTestThreshold, which defaults to 300ms and is not overridden invite.config.ts, and every test in a file that failed, whatever its duration. Every firestore and database health check in that run cleared 300ms, so all 120 printed on the first case alone, and they are inprobe-logs-node22andprobe-logs-node24.To pull them out:
double check - emulator is runningline that follows it.✓and a failing one is❯, so matching only✓silently drops every flake iteration. That is the one trap in doing this.Two checks that the extraction is sound:
What they show
Firestore health check, in ms:
auth.test.tsxprinted a line in any of the 120 iterations, and since that file never failed, that puts every test in it under the 300ms threshold. The whole file runs in 315ms to 556ms, against 1509ms to 1899ms for firestore's health check on its own.With n = 9 this can only exclude a large effect, the same limit you put on the override comparison. But there is no separation visible at all rather than a small one, and it does answer half the split you set up: two different emulators were answering at their normal speed on the iterations that failed, so runner-wide contention does not look like the trigger.
Why I think it comes out flat: the health check is on the wrong stream
addDoc, so it times connection setup plus a write round trip.waitForcalls wait on the Listen stream.test/firestore.test.tsx:111observesuseFirestoreDocDataand:180observesuseFirestoreDoc, both of which resolve to rxfire'sdoc/docData. That rxfire module imports onlyonSnapshot,refEqualandgetCountFromServerfromfirebase/firestore, so even theOncevariants are a listener withfirst()applied, not a one-shot read.RPC 'Listen' streamto 8RPC 'Write' stream, with no other RPC appearing. That is lines rather than runs, since one run can log several.So the health check is a good detector for the runner-wide half of your split and a blind one for the other half, which is the half the evidence points at.
If the recovered numbers look wrong to you I would like to know, since the whole table rests on parsing durations out of a reporter that was never meant to be parsed.
tyler-reitz commentedon Aug 11, 2026
The mechanism is closed end to end, from artifacts run 31223456796 already produced plus SDK source. A flake is the frame desync landing on the Listen stream before the two cache-miss tests run, after which the Firestore SDK parks the stream at maximum backoff for 60 seconds. No dispatch was needed.
1. The failing tests are not slow, they are stalled. Following your recovery method: neither flaking test printed a per-test line on any of the 111 passing iterations, so both always finished under the 300ms slow threshold (control: 1600
✓ … Nmslines across the artifacts). On all 9 failures both printed, at 1040-1083ms and 1015-1022ms, which is the 1000mswaitFordefault plus overhead. At least 700ms of headroom on every pass, pinned at the timeout on every failure.2. The desync fires at a fixed suite position. In all 31 desync runs it lands right after
test/functions.test.tsxcompletes (~1.6s in). It is not random noise across the run.3. Position relative to the two vulnerable tests decides the outcome. vitest attributes the stderr to the firestore test in flight: all 9 flakes had it during
useFirestoreDoc > can get a Firestore document, before both vulnerable tests; the passing desyncs sat mostly in the collection tests, after them.4. The control: only RESOURCE_EXHAUSTED kills the run. The same logs hold 19 Listen-stream deaths with codes 13/14/1, including 5 at that same early position. Every one passed. The difference is in the SDK:
error.code === RESOURCE_EXHAUSTED → backoff.resetToMax()withDEFAULT_BACKOFF_MAX_DELAY_MS = 60 * 1000(@firebase/firestore11.10.0,index.node.cjs.js:23090-23094,:22767), logged as "Using maximum backoff delay to prevent overloading the backend", visible in the flake logs. Codes 13/14/1 get normal backoff and recover in well under a second.5. Why only those two tests: every other firestore test writes first and is served by latency compensation from the local cache. Only the two nonexistent-doc reads need a live server round trip.
This retires the timing lead I left in comment 5222993659: the 1058ms/1142ms local failures were the same timeout, not round trips finishing just over the line. It also explains why no timeout increase can help (60s ≫ any budget), and the recorded 120s hang is plausibly the same mechanism, though that is inference, not verified.
Still open: what corrupts the frame. CI-only (0 in 240 local iterations, 0 in 120 firestore-only), at a fixed suite offset that coincides with a vitest worker freeing up and starting the next file. That fixed-position signature is a strong repro report for upstream (emulator or grpc-js), and it is narrower than anything we have had on this.
Proposed mitigation, locally verified for no-regression:
initializeFirestore(app, { experimentalForceLongPolling: true })in the test harness. It bypasses the grpc path entirely, which removes the desync class regardless of where the corruption lives. Local runs with it: full suite green on React 18 and 19 (environmental functions failures aside), and 6 of 7 firestore-only runs clean, with the one 2-failure run being the first, cold-start suspected, log not kept, so it stays unexplained rather than explained away. What local runs cannot show is the fix itself, since the desync has never reproduced locally (0 in 240 iterations). The proof is a probe dispatch after it lands: at the historical 26% per-run desync rate, 30 clean iterations is p ≈ 1×10⁻⁴ against no-effect, assuming runs are independent. Two costs stated plainly: the suite stops exercising the grpc transport production Node users get, and the upstream bug stays unfixed, which is what the fixed-position repro signature above is for.Plan from here: merge #787 (the probe with baseline-only default and per-iteration recording), open the long-polling change as its own PR, dispatch 30 iterations once it lands, and close this on a clean count. #783 then removes the probe, and the corruption signature goes upstream as a report rather than staying our bug to chase.
ci(probe): record emulator health-check durations per iteration (#787)
tyler-reitz commentedon Aug 11, 2026
Correction to the mitigation above:
experimentalForceLongPollingdoes nothing in this test suite. Before pushing it I ran a transport probe with debug logging against the emulator, and the connection isGrpcConnectionwith the flag on or off. The source confirms it: the Node build'snewConnectionreturnsGrpcConnectionunconditionally (index.node.cjs.js:22685-88), and no WebChannel implementation exists in that bundle, so the setting parses and then has nothing to act on. My "verified locally" above was a check that could not fail: a green suite with an inert flag proves nothing, and the tests demonstrably run the Node build, which is why CI's desync lines sayGrpcConnection.Replacement, same goal by a different route:
fileParallelism: falsein the vitest config. The evidence for it is already in this thread: 0 desyncs in 120 firestore-only runs vs 31 in 120 full-suite on the same hardware, and the desync fires exactly when a worker frees up and starts the next file mid-Listen. Serializing files removes the trigger rather than the transport.Three local serial runs are clean apart from the known Node-version functions failures, and unlike the flag this cannot be silently inert: wall time moves from 42s to ~50s locally, so expect +10-20s per CI test job. One more datapoint from timing that baseline: the two tests failed a parallel local run for the first time, pinned at the timeout with no desync in the log. So contention alone is a second, rarer path to the same symptom, serialization plausibly helps both, and the probe readout should count desyncs and test failures separately rather than assuming one implies the other.
PR coming; the probe dispatch stays the proof, same 30-iteration bar.
test(firestore): let the offline fallback rescue two cache-miss tests
test(firestore): let the offline fallback rescue two cache-miss tests (…
tyler-reitz commentedon Aug 17, 2026
Closing this. #791 landed as
23da94eand the proof run is clean.Probe run 32061237569 on the merge commit, 30 full-suite iterations per Node version, same workload as the 2026-08-11 baseline:
The desync rate is statistically unchanged, which is the point: the emulator still corrupts the Listen frame and the SDK still parks the stream on maximum backoff. What stopped is the conversion into a test failure.
Five of those 13 desyncs landed on
returns undefined if document does not exist, which passed at 9820, 9857, 9818, 9858 and 9861ms. Under the old 1000mswaitFordefault every one would have failed, and vitest would have killed them at its 5000ms default regardless.The ~9.8s clustering is the offline fallback at
ONLINE_STATE_TIMEOUT_MS, not a stream reconnect. An offline client raises the pending snapshot from the local cache, and these two tests assert that a document is absent, which is exactly what an empty cache reports.The upstream bug (firebase-tools#8654) is untouched, so this survives it rather than fixes it. The cost is that a genuine regression in those two tests now takes up to 120s to surface instead of 1s.
#783 tracks removing the probe workflow.
chore(ci): remove the firestore flake probe workflow (#795)
ci: run the test suite against React 19 (FirebaseExtended#789)