Is there an existing issue for this?
How do you use Sentry?
Self-hosted/on-premise
Which SDK are you using?
sentry-miniapp 1.20.1 (community SDK wrapping @sentry/core)
(issue exists in @sentry/core -- @isaacs)
SDK Version
10.72.0
Link to Sentry event
No response
Reproduction Example/SDK Setup
Plain Node, no framework, no mini-game runtime, no network — only @sentry/core is involved.
const { makeOfflineTransport, createTransport, createEnvelope } = require('@sentry/core');
const envelope = createEnvelope(
{ event_id: 'abc', sent_at: new Date().toISOString() },
[[{ type: 'event' }, { message: 'hi' }]],
);
function underlyingTransportCalledSynchronously(withShouldSend) {
let called = false;
const base = (o) => createTransport(o, () => {
called = true;
return Promise.resolve({ statusCode: 200 });
});
const opts = {
recordDroppedEvent: () => {},
createStore: () => ({
push: async () => {}, unshift: async () => {}, shift: async () => undefined,
}),
};
if (withShouldSend) opts.shouldSend = () => true; // synchronous boolean — allowed by the type
makeOfflineTransport(base)(opts).send(envelope).catch(() => {});
return called;
}
console.log('with sync shouldSend:', underlyingTransportCalledSynchronously(true));
console.log('without shouldSend: ', underlyingTransportCalledSynchronously(false));
Actual output on @sentry/core 10.72.0:
with sync shouldSend: false
without shouldSend: true
Steps to Reproduce
npm install @sentry/core@10.72.0
- Save the script from "Reproduction Example" above as
repro.js.
- Run
node repro.js.
It builds a makeOfflineTransport twice over the same underlying transport — once
with shouldSend: () => true (a synchronous boolean, which the type
(envelope) => boolean | Promise<boolean> explicitly allows), once without it —
and records whether the underlying transport's send was invoked before
.send(envelope) returned.
Expected Result
With a synchronous shouldSend, transport.send(envelope) should be reached within
the same synchronous execution turn, exactly as it is when no shouldSend is
configured. Nothing genuinely asynchronous happens in either case, so the presence
of a synchronous callback should not change when the underlying transport is called.
Actual Result
with sync shouldSend: false
without shouldSend: true
The await on line
https://github.com/getsentry/sentry-javascript/blob/10.72.0/packages/core/src/transports/offline.ts
(if (options.shouldSend && (await options.shouldSend(envelope)) === false)) yields a
microtask even when the callback returns a plain boolean, so transport.send(envelope)
is not merely awaited later — it is not called until the next microtask.
Additional Context
Two notes on the form fields above, since none of the options fit exactly:
- Affected package is
@sentry/core (packages/core/src/transports/offline.ts),
not @sentry/browser — the dropdown has no @sentry/core option, so I picked the
closest one. We reach this code through sentry-miniapp, a community SDK for
Chinese mini-program / mini-game platforms that wraps @sentry/core.
- On "I am using the latest SDK release": we tested on 10.72.0, not the latest.
I verified that this code path is unchanged in 10.73.0 (the current latest), so the
issue is still present on the latest release — but I want to be explicit that the
measurements below were taken on 10.72.0.
- The host is Douyin (抖音), ByteDance's mainland-China app — not TikTok. Its
mini-game runtime exposes host APIs under the global tt.* namespace, which is
easy to misread.
Summary
makeOfflineTransport's send() unconditionally awaits the shouldSend callback:
// packages/core/src/transports/offline.ts @ 10.72.0
// https://github.com/getsentry/sentry-javascript/blob/10.72.0/packages/core/src/transports/offline.ts
async function send(envelope, isRetry = false) {
if (!isRetry && envelopeContainsItemType(envelope, ['replay_event', 'replay_recording'])) {
await store.push(envelope);
flushIn(MIN_DELAY);
return {};
}
try {
if (options.shouldSend && (await options.shouldSend(envelope)) === false) {
throw new Error('Envelope not sent because `shouldSend` callback returned false');
}
const result = await transport.send(envelope);
...
shouldSend is typed as (envelope) => boolean | Promise<boolean>. When an integrator supplies the synchronous form — explicitly allowed by the type, and the natural shape for a consent/authorization gate — this await still yields a microtask before transport.send(envelope) is even called.
On most hosts this is invisible. On hosts that suspend JS execution at lifecycle boundaries, that single hop is the difference between "the request went out" and "the event is gone".
Why it matters
Douyin mini-games freeze the entire JS thread the moment the tt.onHide callback returns — both the microtask and the macrotask queues stop until the next tt.onShow. (Reproduced consistently on the device listed above; we have not tested across other devices or host versions.) Background networking itself is not blocked: a tt.request() issued synchronously inside the onHide callback goes out on the wire. What's blocked is not the send — it's reaching the line that sends.
So an event captured during onHide has to reach transport.send() within the same synchronous execution turn. @sentry/core is capable of that: as long as the event-processing hooks return synchronously, there is no true async boundary between captureEvent → _prepareEvent → sendEnvelope → transport → promise buffer. SyncPromise resolves non-thenable values synchronously, and an async function body likewise runs synchronously up to its first await.
That await in makeOfflineTransport is the first real async boundary — and it sits at an unfortunate spot, before the offline store can protect anything:
- the envelope has not been serialized yet (
serializeEnvelope lives further down, in the base transport)
transport.send() was never called, so the try block never reaches anything that could throw
- and for a non-replay envelope such as this one, persistence only happens in the
catch branch (either transport.send() throws, or shouldSend returned false — and shouldQueue() / options.shouldStore doesn't veto it). Replay envelopes have their own pre-emptive store.push ahead of the try, but that path does not apply here. Here shouldSend returned true, so catch never runs and the offline store never sees the event.
At the freeze point the event exists only as a pending promise continuation on the JS heap. If the OS reclaims the process while the app is backgrounded, the event is lost permanently and silently — flushAtStartup finds nothing on disk to retry.
We hit this through sentry-miniapp, which uses shouldSend as a privacy-consent gate:
// sentry-miniapp src/client.ts
makeOfflineTransport(() => baseTransport)({
...transportOptions,
createStore: (o) => createMiniappOfflineStore({ ... }),
shouldSend: () => isConsentGranted(), // returns a plain boolean
flushAtStartup: true,
});
// sentry-miniapp src/consent.ts
export function isConsentGranted(): boolean {
return !_config.required || _granted;
}
Background
That SDK has a second break of the same kind in its own _prepareEvent override (it wraps core's SyncPromise in a native Promise.resolve()). It is tracked separately in its own repository: lizhiyao/sentry-miniapp#358. Both have to be fixed for the event to go out within the onHide synchronous turn. This issue only concerns the @sentry/core half.
Reproduction
Minimal repro, no host involved — plain Node, no mini-game runtime, no network:
const { makeOfflineTransport, createTransport, createEnvelope } = require('@sentry/core');
const envelope = createEnvelope(
{ event_id: 'abc', sent_at: new Date().toISOString() },
[[{ type: 'event' }, { message: 'hi' }]],
);
function underlyingTransportCalledSynchronously(withShouldSend) {
let called = false;
const base = (o) => createTransport(o, () => {
called = true;
return Promise.resolve({ statusCode: 200 });
});
const opts = {
recordDroppedEvent: () => {},
createStore: () => ({
push: async () => {}, unshift: async () => {}, shift: async () => undefined,
}),
};
if (withShouldSend) opts.shouldSend = () => true; // synchronous boolean — allowed by the type
makeOfflineTransport(base)(opts).send(envelope).catch(() => {});
return called;
}
console.log('with sync shouldSend:', underlyingTransportCalledSynchronously(true));
console.log('without shouldSend: ', underlyingTransportCalledSynchronously(false));
Actual output on @sentry/core 10.72.0:
with sync shouldSend: false
without shouldSend: true
Nothing genuinely asynchronous happens in either case, yet the mere presence of a synchronous shouldSend defers the underlying transport.send() past the current turn. This isolates the await as the only variable.
On-device confirmation
Douyin mini-game on a physical iPhone, with a packet capture plus two instrumentation logs inserted into the compiled bundle:
| Log |
Location |
O |
entry of makeOfflineTransport's send(), before await options.shouldSend(...) |
G |
immediately before the transport calls the host's request() — i.e. after that await |
The single variable is whether a synchronous shouldSend is present on the offline transport. Rather than toggling sentry-miniapp's consent mode (which would also swap the offline store's configuration), we injected shouldSend: () => true directly into the transport options — the same shape the SDK's consent gate produces, with nothing else changed. All runs below have the sentry-miniapp fix from Background applied, because that break sits earlier in the chain and would otherwise mask this one (it stops the event before O is even reached).
| Run |
sync shouldSend |
this fix |
O |
G |
envelope at background |
| 1 |
absent |
n/a |
yes |
yes |
yes |
| 2 |
present |
not applied |
yes |
no |
no |
| 3 |
present |
applied |
yes |
yes |
yes |
Run 2 is the failure, and O present / G absent localizes it to that single await: execution enters send() synchronously, yields at the await, and transport.send() is never called before the freeze. Run 1 and run 3 both show the envelope leaving the device in the same instant the app is backgrounded.
We also ran the fixed combination under the SDK's real consent configuration (requireConsent: true, so shouldSend is the SDK's own () => isConsentGranted() on the consent-gated transport rather than our injected stub) — the envelope goes out at background there too. So the result is not an artifact of the injection.
A control request issued synchronously from the same onHide callback goes out in all three runs — confirming the constraint is not background networking itself.
One methodological note, since it cost us a wrong conclusion: our first pass at this A/B produced contradictory results because the IDE silently reused a previously compiled bundle. We now stamp each build with a serial number printed at module load and verify it on the device before trusting a run. The table above is from stamped runs only.
Suggested fix
Only await when there is actually something to await:
try {
- if (options.shouldSend && (await options.shouldSend(envelope)) === false) {
- throw new Error('Envelope not sent because `shouldSend` callback returned false');
+ if (options.shouldSend) {
+ const decision = options.shouldSend(envelope);
+ // Awaiting a plain boolean still costs a microtask tick. Some hosts (e.g. Douyin
+ // mini-games) freeze JS execution when the app is backgrounded, so a single
+ // unnecessary hop is enough to prevent the request from ever being issued.
+ const shouldSend = isThenable(decision) ? await decision : decision;
+ if (shouldSend === false) {
+ throw new Error('Envelope not sent because `shouldSend` callback returned false');
+ }
}
const result = await transport.send(envelope);
isThenable is already exported from @sentry/core. Behaviour is unchanged for callbacks that return a promise; for synchronous callbacks, the only difference is that transport.send() is now reached within the same turn.
The following await transport.send(envelope) needs no change — the call happens synchronously; only its continuation is deferred.
If you'd like to lock this property down, it may be worth documenting that "makeOfflineTransport().send() reaches the underlying transport synchronously when shouldSend is synchronous" and adding a unit test for it. For any host that may suspend JS at a lifecycle boundary, that property is load-bearing.
Priority
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it.
Is there an existing issue for this?
How do you use Sentry?
Self-hosted/on-premise
Which SDK are you using?
sentry-miniapp 1.20.1 (community SDK wrapping @sentry/core)
(issue exists in
@sentry/core-- @isaacs)SDK Version
10.72.0
Link to Sentry event
No response
Reproduction Example/SDK Setup
Plain Node, no framework, no mini-game runtime, no network — only @sentry/core is involved.
Actual output on
@sentry/core10.72.0:Steps to Reproduce
npm install @sentry/core@10.72.0repro.js.node repro.js.It builds a
makeOfflineTransporttwice over the same underlying transport — oncewith
shouldSend: () => true(a synchronous boolean, which the type(envelope) => boolean | Promise<boolean>explicitly allows), once without it —and records whether the underlying transport's
sendwas invoked before.send(envelope)returned.Expected Result
With a synchronous
shouldSend,transport.send(envelope)should be reached withinthe same synchronous execution turn, exactly as it is when no
shouldSendisconfigured. Nothing genuinely asynchronous happens in either case, so the presence
of a synchronous callback should not change when the underlying transport is called.
Actual Result
with sync shouldSend: false
without shouldSend: true
The
awaiton linehttps://github.com/getsentry/sentry-javascript/blob/10.72.0/packages/core/src/transports/offline.ts
(
if (options.shouldSend && (await options.shouldSend(envelope)) === false)) yields amicrotask even when the callback returns a plain boolean, so
transport.send(envelope)is not merely awaited later — it is not called until the next microtask.
Additional Context
Two notes on the form fields above, since none of the options fit exactly:
@sentry/core(packages/core/src/transports/offline.ts),not
@sentry/browser— the dropdown has no@sentry/coreoption, so I picked theclosest one. We reach this code through
sentry-miniapp, a community SDK forChinese mini-program / mini-game platforms that wraps
@sentry/core.I verified that this code path is unchanged in 10.73.0 (the current latest), so the
issue is still present on the latest release — but I want to be explicit that the
measurements below were taken on 10.72.0.
mini-game runtime exposes host APIs under the global
tt.*namespace, which iseasy to misread.
Summary
makeOfflineTransport'ssend()unconditionallyawaits theshouldSendcallback:shouldSendis typed as(envelope) => boolean | Promise<boolean>. When an integrator supplies the synchronous form — explicitly allowed by the type, and the natural shape for a consent/authorization gate — thisawaitstill yields a microtask beforetransport.send(envelope)is even called.On most hosts this is invisible. On hosts that suspend JS execution at lifecycle boundaries, that single hop is the difference between "the request went out" and "the event is gone".
Why it matters
Douyin mini-games freeze the entire JS thread the moment the
tt.onHidecallback returns — both the microtask and the macrotask queues stop until the nexttt.onShow. (Reproduced consistently on the device listed above; we have not tested across other devices or host versions.) Background networking itself is not blocked: att.request()issued synchronously inside theonHidecallback goes out on the wire. What's blocked is not the send — it's reaching the line that sends.So an event captured during
onHidehas to reachtransport.send()within the same synchronous execution turn.@sentry/coreis capable of that: as long as the event-processing hooks return synchronously, there is no true async boundary betweencaptureEvent→_prepareEvent→sendEnvelope→ transport → promise buffer.SyncPromiseresolves non-thenable values synchronously, and anasyncfunction body likewise runs synchronously up to its firstawait.That
awaitinmakeOfflineTransportis the first real async boundary — and it sits at an unfortunate spot, before the offline store can protect anything:serializeEnvelopelives further down, in the base transport)transport.send()was never called, so thetryblock never reaches anything that could throwcatchbranch (eithertransport.send()throws, orshouldSendreturnedfalse— andshouldQueue()/options.shouldStoredoesn't veto it). Replay envelopes have their own pre-emptivestore.pushahead of thetry, but that path does not apply here. HereshouldSendreturnedtrue, socatchnever runs and the offline store never sees the event.At the freeze point the event exists only as a pending promise continuation on the JS heap. If the OS reclaims the process while the app is backgrounded, the event is lost permanently and silently —
flushAtStartupfinds nothing on disk to retry.We hit this through
sentry-miniapp, which usesshouldSendas a privacy-consent gate:Background
That SDK has a second break of the same kind in its own
_prepareEventoverride (it wraps core'sSyncPromisein a nativePromise.resolve()). It is tracked separately in its own repository: lizhiyao/sentry-miniapp#358. Both have to be fixed for the event to go out within theonHidesynchronous turn. This issue only concerns the@sentry/corehalf.Reproduction
Minimal repro, no host involved — plain Node, no mini-game runtime, no network:
Actual output on
@sentry/core10.72.0:Nothing genuinely asynchronous happens in either case, yet the mere presence of a synchronous
shouldSenddefers the underlyingtransport.send()past the current turn. This isolates theawaitas the only variable.On-device confirmation
Douyin mini-game on a physical iPhone, with a packet capture plus two instrumentation logs inserted into the compiled bundle:
OmakeOfflineTransport'ssend(), beforeawait options.shouldSend(...)Grequest()— i.e. after thatawaitThe single variable is whether a synchronous
shouldSendis present on the offline transport. Rather than togglingsentry-miniapp's consent mode (which would also swap the offline store's configuration), we injectedshouldSend: () => truedirectly into the transport options — the same shape the SDK's consent gate produces, with nothing else changed. All runs below have thesentry-miniappfix from Background applied, because that break sits earlier in the chain and would otherwise mask this one (it stops the event beforeOis even reached).shouldSendOGRun 2 is the failure, and
Opresent /Gabsent localizes it to that singleawait: execution enterssend()synchronously, yields at theawait, andtransport.send()is never called before the freeze. Run 1 and run 3 both show the envelope leaving the device in the same instant the app is backgrounded.We also ran the fixed combination under the SDK's real consent configuration (
requireConsent: true, soshouldSendis the SDK's own() => isConsentGranted()on the consent-gated transport rather than our injected stub) — the envelope goes out at background there too. So the result is not an artifact of the injection.A control request issued synchronously from the same
onHidecallback goes out in all three runs — confirming the constraint is not background networking itself.Suggested fix
Only await when there is actually something to await:
try { - if (options.shouldSend && (await options.shouldSend(envelope)) === false) { - throw new Error('Envelope not sent because `shouldSend` callback returned false'); + if (options.shouldSend) { + const decision = options.shouldSend(envelope); + // Awaiting a plain boolean still costs a microtask tick. Some hosts (e.g. Douyin + // mini-games) freeze JS execution when the app is backgrounded, so a single + // unnecessary hop is enough to prevent the request from ever being issued. + const shouldSend = isThenable(decision) ? await decision : decision; + if (shouldSend === false) { + throw new Error('Envelope not sent because `shouldSend` callback returned false'); + } } const result = await transport.send(envelope);isThenableis already exported from@sentry/core. Behaviour is unchanged for callbacks that return a promise; for synchronous callbacks, the only difference is thattransport.send()is now reached within the same turn.The following
await transport.send(envelope)needs no change — the call happens synchronously; only its continuation is deferred.If you'd like to lock this property down, it may be worth documenting that "
makeOfflineTransport().send()reaches the underlying transport synchronously whenshouldSendis synchronous" and adding a unit test for it. For any host that may suspend JS at a lifecycle boundary, that property is load-bearing.Priority
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding
+1orme too, to help us triage it.