When Two Profilers Share a Thread
Profilers have a reputation for being straightforward. You turn them on, they collect samples, and the output is supposed to tell you what the program was doing. In practice, the interesting bugs are often not in the sampling itself, but in the handoff between layers.
In Chromium, that handoff became subtle when the JavaScript Self-Profiling API and internal tracing were active at the same time. Both of them could emit CPU profile streams for the same thread. The problem was not that the sampler was wrong; it was that DevTools had no reliable way to tell which stream a sample belonged to.
The Bug: Quietly Corrupting Trace Timelines
The issue was tracked under Chromium Issue 375614293. When multiple profiling sources ran concurrently, DevTools historically had a critical limitation: it merged all concurrent CPU profile streams on the same thread into a single jumbled timeline.
For instance, if a site utilized the page-initiated JS Self-Profiling API for real-user monitoring (RUM) metrics, and concurrently an engineer opened the performance panel to record a trace or turned on internal V8 tracing, their respective samples would collide. DevTools would interleave the sample ticks from different configurations, completely corrupting JavaScript call frame attribution. This did not crash the browser, but it quietly poisoned the accuracy of performance reports with impossible call stacks.
Step 1: Tagging the Source in V8
To resolve this, we had to carry attribution metadata from the very bottom of the execution stack up to the front-end parser. The first change took place in V8's public profiler header (include/v8-profiler.h). We
introduced a new CpuProfileSource enum class to tag what component triggered the profile stream:
namespace v8 {
/**
* Identifies which component initiated CPU profiling for proper attribution.
*/
enum class CpuProfileSource : uint8_t {
/** Default value when no explicit source is specified. */
kUnspecified = 0,
/** Profiling initiated via the DevTools Inspector protocol. */
kInspector = 1,
/** Profiling initiated by the embedder (e.g., Blink) via self-profiling API. */
kSelfProfiling = 2,
/** Profiling initiated internally by V8 (e.g., tracing CPU profiler). */
kInternal = 3,
};
}
Whenever V8 registers a new profiling session, it preserves this enum. When generating trace events, V8 uses this tag to append an explicit source attribute to the arguments payload of its
"Profile" and "ProfileChunk" trace events.
Step 2: Propagating the Tag in Chromium
Next, we needed the browser engine's embedder layer to pass this context. In the Chromium repository, we modified the Blink-side JS Self-Profiling integration. When starting the underlying V8 CPU profile, Blink now specifies
the v8::CpuProfileSource::kSelfProfiling origin tag.
As a result, V8 traces for these API runs started emitting trace structures enriched with the source attribute:
{
"name": "Profile",
"cat": "disabled-by-default-v8.cpu_profiler",
"ph": "P",
"args": {
"data": {
"source": "SelfProfiling"
}
}
}
Step 3: Disambiguating Streams in DevTools
The final step was updating Chrome DevTools' trace engine to interpret these tags. In the devtools-frontend repository, we updated the trace event types in
front_end/models/trace/types/TraceEvents.ts to ingest the new optional metadata field:
export type ProfileSource = 'Inspector' | 'SelfProfiling' | 'Internal';
export const VALID_PROFILE_SOURCES: readonly ProfileSource[] = ['Inspector', 'SelfProfiling', 'Internal'] as const;
Then, we refactored DevTools' CPU sample trace handler (front_end/models/trace/handlers/SamplesHandler.ts). Instead of merging every candidate profile stream on a thread, we set up a robust priority queue to
select exactly one stream per thread based on the profiling mode:
/**
* Profile source selection priority when multiple profiles exist for the same thread.
*
* Profile sources and their typical scenarios:
* - 'Internal': Browser-initiated profiling performance panel traces.
* This is the profiling mechanism when users click "Record" in the Devtools UI.
* - 'Inspector': User-initiated via console.profile()/profileEnd() calls.
* Represents explicit developer intent to profile specific code.
* - 'SelfProfiling': Page-initiated via JS Self-Profiling API.
* Lower signal vs the two above; treated as fallback.
*
* Selection strategy:
* - CPU Profile mode: Prefer 'Inspector' (explicit user request).
* - Performance trace: Prefer 'Internal' (integrated timeline context), then 'Inspector'.
* - Sources not in the priority list (including 'SelfProfiling') act as fallbacks.
* When no priority source matches, the first candidate profile is selected.
*/
const PROFILE_SOURCES_BY_PRIORITY = {
cpuProfile: ['Inspector'] as Types.Events.ProfileSource[],
performanceTrace: ['Internal', 'Inspector'] as Types.Events.ProfileSource[],
};
Now, when SamplesHandler processes a trace, it groups profile candidates by thread ID, determines the active profile mode, matches candidates against the priority list, and selects the single most relevant stream:
const priorityList = parseOptions.isCPUProfile
? PROFILE_SOURCES_BY_PRIORITY.cpuProfile
: PROFILE_SOURCES_BY_PRIORITY.performanceTrace;
// Selection loop
let chosen = candidates[0];
for (const source of priorityList) {
const match = candidates.find(p => p.data.source === source);
if (match) {
chosen = match;
break;
}
}
The engine then isolates and builds the final CPUProfileDataModel exclusively using the samples of the chosen profile candidate. It discards other concurrent candidates on the same thread, preventing sample
pollution. This selection maintains backward compatibility: if trace files lack the source tag (e.g. legacy traces), the parser gracefully falls back to the first available candidate.
The Result
By aligning the metadata plane across V8, Blink, and DevTools, we turned a quiet and frustrating telemetry corruption case into a reliable, predictable system. Developers can now run real-user monitoring libraries utilizing JS Self-Profiling concurrently with local DevTools recordings, knowing that their profile streams will remain cleanly isolated.
References
- V8 CL 7122240: Reland [profiler] Add CpuProfileSource
- Chromium CL 6874588: Tag JS Self Profiling CPU streams as "SelfProfiling"
- DevTools CL 6877206: Select one CPU profile stream per thread by source