Skip to content

span.sync incorrectly true for async spans on Node.js >= 24 — AsyncContextFrame makes executionAsyncId() return 0 in promise continuations, so every DB/HTTP span shows a "blocking" badge in Kibana #5148

Description

@rvamsikrishna

Describe the bug

After upgrading our services from Node.js 20 to Node.js 24 (agent unchanged), virtually every span — pg queries, outgoing HTTP, manual exit spans — is now reported with sync: true. In the Kibana APM trace waterfall this renders a "blocking" badge on nearly every span of every trace, which misleadingly suggests widespread event-loop blocking. Span durations and trace structure are unaffected; only the sync flag changed.

Root cause

span.sync is computed by comparing async_hooks.executionAsyncId() at span start and end:

On Node <= 22 this worked as a side effect of the agent's AsyncLocalStorageRunContextManager: AsyncLocalStorage was implemented on top of async_hooks, which enabled the V8 promise hook, so every promise continuation had a distinct async ID.

Node.js 24 enables AsyncContextFrame as the default AsyncLocalStorage implementation. No promise hook is installed anymore, so executionAsyncId() returns 0 inside promise continuations. Any span that starts inside an await chain and ends in a promise continuation (i.e. essentially every instrumented span in an async application — the pg instrumentation, for example, ends its span in a promise continuation in lib/instrumentation/modules/pg.js) compares 0 === 0 and keeps its initial sync = true.

Steps to reproduce

'use strict';
const apm = require('elastic-apm-node').start({
  serviceName: 'sync-repro',
  disableSend: true,
  centralConfig: false,
  cloudProvider: 'none',
  metricsInterval: '0s',
});
const { setTimeout: sleep } = require('timers/promises');

async function main() {
  const tx = apm.startTransaction('tx');
  await sleep(1); // be inside a promise continuation, like any real request handler

  const span = apm.startSpan('SELECT FROM foo', 'db', 'postgresql', 'query', {
    exitSpan: true,
  });
  await sleep(5); // async I/O — this span must NOT be reported as sync/blocking
  span.end();

  tx.end();
  console.log(`${process.version} span.sync = ${span.sync}`);
  process.exit(0);
}
main();

Output:

$ node20 repro.js
v20.20.0 span.sync = false            # correct

$ node24 repro.js
v24.13.0 span.sync = true             # BUG — async span reported as blocking

$ node24 --no-async-context-frame repro.js
v24.13.0 span.sync = false            # correct again, isolating the cause to AsyncContextFrame

The --no-async-context-frame run pinpoints the trigger: it is exactly Node's switch of AsyncLocalStorage to AsyncContextFrame.

Expected behavior

Spans covering async I/O report sync: false (or omit the optional sync field), as they did on Node <= 22, so the Kibana "blocking" badge only appears on genuinely synchronous spans.

Environment

  • OS: reproduced on Windows 11 and Linux (EKS) containers
  • Node.js version: v24.13.0 (works correctly on v20.20.0, and on v24.13.0 with --no-async-context-frame)
  • APM Server version: 8.x
  • Agent version: 4.15.0 (also reproduced on 4.8.0)

How are you starting the agent?

  • Calling agent.start() directly (e.g. require('elastic-apm-node').start(...))
  • Requiring elastic-apm-node/start from within the source code
  • Starting node with -r elastic-apm-node/start

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions