Skip to content

makeOfflineTransport awaits a synchronous shouldSend, delaying transport.send() by a microtask #24005

Description

@yaoxp

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

  1. npm install @sentry/core@10.72.0
  2. Save the script from "Reproduction Example" above as repro.js.
  3. 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_prepareEventsendEnvelope → 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 silentlyflushAtStartup 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BugCorejavascriptPull requests that update javascript code

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions