Skip to content

Commit e1fa634

Browse files
committed
feat: use yielding to main thread in more places
1 parent 8fc7045 commit e1fa634

File tree

10 files changed

+674
-78
lines changed

10 files changed

+674
-78
lines changed

.changeset/smooth-wolves-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
feat: introduce a task queue that will yield to the main thread periodically reducing the impact of long operations

packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { assignableWindow, LazyLoadedSessionRecordingInterface, window, document
3535
import { addEventListener } from '../../../utils'
3636
import { MutationThrottler } from './mutation-throttler'
3737
import { createLogger } from '../../../utils/logger'
38+
import { processWithYield } from '../../../utils/task-queue'
3839
import {
3940
clampToRange,
4041
includes,
@@ -1068,17 +1069,23 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
10681069

10691070
if (this._buffer.data.length > 0) {
10701071
const snapshotEvents = splitBuffer(this._buffer)
1071-
snapshotEvents.forEach((snapshotBuffer) => {
1072-
this._flushedSizeTracker?.trackSize(snapshotBuffer.size)
1073-
this._captureSnapshot({
1074-
$snapshot_bytes: snapshotBuffer.size,
1075-
$snapshot_data: snapshotBuffer.data,
1076-
$session_id: snapshotBuffer.sessionId,
1077-
$window_id: snapshotBuffer.windowId,
1078-
$lib: 'web',
1079-
$lib_version: Config.LIB_VERSION,
1080-
})
1081-
})
1072+
// Process snapshot captures with yielding to avoid blocking on large buffers
1073+
// Fire-and-forget: each _captureSnapshot is independent
1074+
processWithYield(
1075+
snapshotEvents,
1076+
(snapshotBuffer) => {
1077+
this._flushedSizeTracker?.trackSize(snapshotBuffer.size)
1078+
this._captureSnapshot({
1079+
$snapshot_bytes: snapshotBuffer.size,
1080+
$snapshot_data: snapshotBuffer.data,
1081+
$session_id: snapshotBuffer.sessionId,
1082+
$window_id: snapshotBuffer.windowId,
1083+
$lib: 'web',
1084+
$lib_version: Config.LIB_VERSION,
1085+
})
1086+
},
1087+
{ timeBudgetMs: 30 }
1088+
)
10821089
}
10831090

10841091
// buffer is empty, we clear it in case the session id has changed

packages/browser/src/extensions/replay/external/network-plugin.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { formDataToQuery } from '../../../utils/request-utils'
1818
import { patch } from '../rrweb-plugins/patch'
1919
import { isHostOnDenyList } from '../../../extensions/replay/external/denylist'
2020
import { defaultNetworkOptions } from './config'
21+
import { processWithYield } from '../../../utils/task-queue'
2122

2223
const logger = createLogger('[Recorder]')
2324

@@ -61,11 +62,18 @@ function initPerformanceObserver(cb: networkCallback, win: IWindow, options: Req
6162
isNavigationTiming(entry) ||
6263
(isResourceTiming(entry) && options.initiatorTypes.includes(entry.initiatorType as InitiatorType))
6364
)
64-
cb({
65-
requests: initialPerformanceEntries.flatMap((entry) =>
66-
prepareRequest({ entry, method: undefined, status: undefined, networkRequest: {}, isInitial: true })
67-
),
68-
isInitial: true,
65+
66+
// Process initial performance entries with yielding for large sets
67+
processWithYield(
68+
initialPerformanceEntries,
69+
(entry) =>
70+
prepareRequest({ entry, method: undefined, status: undefined, networkRequest: {}, isInitial: true }),
71+
{ timeBudgetMs: 30 }
72+
).then((requests) => {
73+
cb({
74+
requests: requests.flat(),
75+
isInitial: true,
76+
})
6977
})
7078
}
7179
const observer = new win.PerformanceObserver((entries) => {

packages/browser/src/posthog-core.ts

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SURVEYS_REQUEST_TIMEOUT_MS,
1212
USER_STATE,
1313
} from './constants'
14+
import { TaskQueue } from './utils/task-queue'
1415
import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture'
1516
import { ExceptionObserver } from './extensions/exception-autocapture'
1617
import { HistoryAutocapture } from './extensions/history-autocapture'
@@ -663,10 +664,6 @@ export class PostHog {
663664
}
664665

665666
private _initExtensions(startInCookielessMode: boolean): void {
666-
// we don't support IE11 anymore, so performance.now is safe
667-
// eslint-disable-next-line compat/compat
668-
const initStartTime = performance.now()
669-
670667
this.historyAutocapture = new HistoryAutocapture(this)
671668
this.historyAutocapture.startIfEnabled()
672669

@@ -733,52 +730,39 @@ export class PostHog {
733730
})
734731

735732
// Process tasks with time-slicing to avoid blocking
736-
this._processInitTaskQueue(initTasks, initStartTime)
737-
}
738-
739-
private _processInitTaskQueue(queue: Array<() => void>, initStartTime: number): void {
740-
const TIME_BUDGET_MS = 30 // Respect frame budget (~60fps = 16ms, but we're already deferred)
741-
742-
while (queue.length > 0) {
743-
// Only time-slice if deferred init is enabled, otherwise run synchronously
744-
if (this.config.__preview_deferred_init_extensions) {
745-
// we don't support IE11 anymore, so performance.now is safe
746-
// eslint-disable-next-line compat/compat
747-
const elapsed = performance.now() - initStartTime
748-
749-
// Check if we've exceeded our time budget
750-
if (elapsed >= TIME_BUDGET_MS && queue.length > 0) {
751-
// Yield to browser, then continue processing
752-
setTimeout(() => {
753-
this._processInitTaskQueue(queue, initStartTime)
754-
}, 0)
755-
return
756-
}
757-
}
758-
759-
// Process next task
760-
const task = queue.shift()
761-
if (task) {
733+
// Only use time-slicing if deferred init is enabled, otherwise process synchronously
734+
if (this.config.__preview_deferred_init_extensions) {
735+
const taskQueue = new TaskQueue({
736+
timeBudgetMs: 30,
737+
onComplete: (totalTimeMs) => {
738+
this.register_for_session({
739+
$sdk_debug_extensions_init_method: 'deferred',
740+
$sdk_debug_extensions_init_time_ms: totalTimeMs,
741+
})
742+
logger.info(`PostHog extensions initialized (${totalTimeMs}ms)`)
743+
},
744+
onError: (error) => {
745+
logger.error('Error initializing extension:', error)
746+
},
747+
})
748+
taskQueue.enqueueAll(initTasks)
749+
} else {
750+
// we don't support IE11 anymore, so performance.now is safe
751+
// eslint-disable-next-line compat/compat
752+
const startTime = performance.now()
753+
initTasks.forEach((task) => {
762754
try {
763755
task()
764756
} catch (error) {
765757
logger.error('Error initializing extension:', error)
766758
}
767-
}
768-
}
769-
770-
// All tasks complete - record timing for both sync and deferred modes
771-
// we don't support IE11 anymore, so performance.now is safe
772-
// eslint-disable-next-line compat/compat
773-
const taskInitTiming = Math.round(performance.now() - initStartTime)
774-
this.register_for_session({
775-
$sdk_debug_extensions_init_method: this.config.__preview_deferred_init_extensions
776-
? 'deferred'
777-
: 'synchronous',
778-
$sdk_debug_extensions_init_time_ms: taskInitTiming,
779-
})
780-
if (this.config.__preview_deferred_init_extensions) {
781-
logger.info(`PostHog extensions initialized (${taskInitTiming}ms)`)
759+
})
760+
// eslint-disable-next-line compat/compat
761+
const totalTimeMs = Math.round(performance.now() - startTime)
762+
this.register_for_session({
763+
$sdk_debug_extensions_init_method: 'synchronous',
764+
$sdk_debug_extensions_init_time_ms: totalTimeMs,
765+
})
782766
}
783767
}
784768

packages/browser/src/request-queue.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { each } from './utils'
33

44
import { isArray, isUndefined, clampToRange } from '@posthog/core'
55
import { logger } from './utils/logger'
6+
import { processWithYield } from './utils/task-queue'
67

78
export const DEFAULT_FLUSH_INTERVAL_MS = 3000
89

@@ -57,22 +58,27 @@ export class RequestQueue {
5758
if (this._isPaused) {
5859
return
5960
}
60-
this._flushTimeout = setTimeout(() => {
61+
this._flushTimeout = setTimeout(async () => {
6162
this._clearFlushTimeout()
6263
if (this._queue.length > 0) {
6364
const requests = this._formatQueue()
64-
for (const key in requests) {
65-
const req = requests[key]
66-
const now = new Date().getTime()
65+
const requestEntries = Object.entries(requests)
66+
const now = new Date().getTime()
6767

68-
if (req.data && isArray(req.data)) {
69-
each(req.data, (data) => {
70-
data['offset'] = Math.abs(data['timestamp'] - now)
71-
delete data['timestamp']
72-
})
73-
}
74-
this._sendRequest(req)
75-
}
68+
// Process timestamp updates with yielding for large batches
69+
await processWithYield(
70+
requestEntries,
71+
([, req]) => {
72+
if (req.data && isArray(req.data)) {
73+
each(req.data, (data) => {
74+
data['offset'] = Math.abs(data['timestamp'] - now)
75+
delete data['timestamp']
76+
})
77+
}
78+
this._sendRequest(req)
79+
},
80+
{ timeBudgetMs: 30 }
81+
)
7682
}
7783
}, this._flushTimeoutMs)
7884
}

packages/browser/src/retry-queue.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { window } from './utils/globals'
66
import { PostHog } from './posthog-core'
77
import { extendURLParams } from './request'
88
import { addEventListener } from './utils'
9+
import { processWithYield } from './utils/task-queue'
910

1011
const thirtyMinutes = 30 * 60 * 1000
1112

@@ -142,9 +143,11 @@ export class RetryQueue {
142143
this._queue = notToFlush
143144

144145
if (toFlush.length > 0) {
145-
for (const { requestOptions } of toFlush) {
146-
this.retriableRequest(requestOptions)
147-
}
146+
// Process retry requests with yielding to avoid blocking on large queues
147+
// Fire-and-forget: each retry is independent
148+
processWithYield(toFlush, ({ requestOptions }) => this.retriableRequest(requestOptions), {
149+
timeBudgetMs: 30,
150+
})
148151
}
149152
}
150153

packages/browser/src/site-apps.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PostHog } from './posthog-core'
22
import { CaptureResult, Properties, RemoteConfig, SiteApp, SiteAppGlobals, SiteAppLoader } from './types'
33
import { assignableWindow } from './utils/globals'
44
import { createLogger } from './utils/logger'
5+
import { processWithYield } from './utils/task-queue'
56

67
const logger = createLogger('[SiteApps]')
78

@@ -88,7 +89,11 @@ export class SiteApps {
8889
const processBufferedEvents = () => {
8990
if (!app.errored && this._bufferedInvocations.length) {
9091
logger.info(`Processing ${this._bufferedInvocations.length} events for site app with id ${loader.id}`)
91-
this._bufferedInvocations.forEach((globals) => app.processEvent?.(globals))
92+
// Process buffered events with yielding to avoid blocking on large queues
93+
// Fire-and-forget: we mark as processed immediately to stop buffering
94+
processWithYield(this._bufferedInvocations, (globals) => app.processEvent?.(globals), {
95+
timeBudgetMs: 30,
96+
})
9297
app.processedBuffer = true
9398
}
9499

0 commit comments

Comments
 (0)