Skip to content
5 changes: 5 additions & 0 deletions .changeset/gc-cleanup-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

fix: Optimized unmount performance by batching cleanup tasks in a central queue.
105 changes: 105 additions & 0 deletions packages/db/src/collection/cleanup-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
type CleanupTask = {
executeAt: number
callback: () => void
}

/**
* Batches many GC registrations behind a single shared timeout.
*/
export class CleanupQueue {
private static instance: CleanupQueue | null = null

private tasks: Map<unknown, CleanupTask> = new Map()

private timeoutId: ReturnType<typeof setTimeout> | null = null
private microtaskScheduled = false

private constructor() {}

public static getInstance(): CleanupQueue {
if (!CleanupQueue.instance) {
CleanupQueue.instance = new CleanupQueue()
}
return CleanupQueue.instance
}

/**
* Queues a cleanup task and defers timeout selection to a microtask so
* multiple synchronous registrations can share one root timer.
*/
public schedule(key: unknown, gcTime: number, callback: () => void): void {
const executeAt = Date.now() + gcTime
this.tasks.set(key, { executeAt, callback })

if (!this.microtaskScheduled) {
this.microtaskScheduled = true
Promise.resolve().then(() => {
this.microtaskScheduled = false
this.updateTimeout()
})
}
}

public cancel(key: unknown): void {
this.tasks.delete(key)
}

/**
* Keeps only one active timeout: whichever task is due next.
*/
private updateTimeout(): void {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}

if (this.tasks.size === 0) {
return
}

let earliestTime = Infinity
for (const task of this.tasks.values()) {
if (task.executeAt < earliestTime) {
earliestTime = task.executeAt
}
}

const delay = Math.max(0, earliestTime - Date.now())
this.timeoutId = setTimeout(() => this.process(), delay)
}

/**
* Runs every task whose deadline has passed, then schedules the next wakeup
* if there is still pending work.
*/
private process(): void {
this.timeoutId = null
const now = Date.now()
for (const [key, task] of this.tasks.entries()) {
if (now >= task.executeAt) {
this.tasks.delete(key)
try {
task.callback()
} catch (error) {
console.error('Error in CleanupQueue task:', error)
}
}
}

if (this.tasks.size > 0) {
this.updateTimeout()
}
}

/**
* Resets the singleton instance for tests.
*/
public static resetInstance(): void {
if (CleanupQueue.instance) {
if (CleanupQueue.instance.timeoutId !== null) {
clearTimeout(CleanupQueue.instance.timeoutId)
}
CleanupQueue.instance = null
}
}
}
20 changes: 5 additions & 15 deletions packages/db/src/collection/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
safeCancelIdleCallback,
safeRequestIdleCallback,
} from '../utils/browser-polyfills'
import { CleanupQueue } from './cleanup-queue'
import type { IdleCallbackDeadline } from '../utils/browser-polyfills'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { CollectionConfig, CollectionStatus } from '../types'
Expand Down Expand Up @@ -34,7 +35,6 @@ export class CollectionLifecycleManager<
public hasBeenReady = false
public hasReceivedFirstCommit = false
public onFirstReadyCallbacks: Array<() => void> = []
public gcTimeoutId: ReturnType<typeof setTimeout> | null = null
private idleCallbackId: number | null = null

/**
Expand Down Expand Up @@ -174,10 +174,6 @@ export class CollectionLifecycleManager<
* Called when the collection becomes inactive (no subscribers)
*/
public startGCTimer(): void {
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
}

const gcTime = this.config.gcTime ?? 300000 // 5 minutes default

// If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled.
Expand All @@ -187,23 +183,20 @@ export class CollectionLifecycleManager<
return
}

this.gcTimeoutId = setTimeout(() => {
CleanupQueue.getInstance().schedule(this, gcTime, () => {
if (this.changes.activeSubscribersCount === 0) {
// Schedule cleanup during idle time to avoid blocking the UI thread
this.scheduleIdleCleanup()
}
}, gcTime)
})
}

/**
* Cancel the garbage collection timer
* Called when the collection becomes active again
*/
public cancelGCTimer(): void {
if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
this.gcTimeoutId = null
}
CleanupQueue.getInstance().cancel(this)
// Also cancel any pending idle cleanup
if (this.idleCallbackId !== null) {
safeCancelIdleCallback(this.idleCallbackId)
Expand Down Expand Up @@ -258,10 +251,7 @@ export class CollectionLifecycleManager<
this.changes.cleanup()
this.indexes.cleanup()

if (this.gcTimeoutId) {
clearTimeout(this.gcTimeoutId)
this.gcTimeoutId = null
}
CleanupQueue.getInstance().cancel(this)

this.hasBeenReady = false

Expand Down
136 changes: 136 additions & 0 deletions packages/db/tests/cleanup-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CleanupQueue } from '../src/collection/cleanup-queue'

describe('CleanupQueue', () => {
beforeEach(() => {
vi.useFakeTimers()
CleanupQueue.resetInstance()
})

afterEach(() => {
vi.useRealTimers()
CleanupQueue.resetInstance()
})

it('batches setTimeout creations across multiple synchronous schedules', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()
const cb2 = vi.fn()

const spySetTimeout = vi.spyOn(global, 'setTimeout')

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 1000, cb2)

expect(spySetTimeout).not.toHaveBeenCalled()

// Process microtasks
await Promise.resolve()

// Should only create a single timeout for the earliest scheduled task
expect(spySetTimeout).toHaveBeenCalledTimes(1)
})

it('executes callbacks after delay', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()

queue.schedule('key1', 1000, cb1)

await Promise.resolve()

expect(cb1).not.toHaveBeenCalled()

vi.advanceTimersByTime(500)
expect(cb1).not.toHaveBeenCalled()

vi.advanceTimersByTime(500)
expect(cb1).toHaveBeenCalledTimes(1)
})

it('can cancel tasks before they run', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()

queue.schedule('key1', 1000, cb1)

await Promise.resolve()

queue.cancel('key1')

vi.advanceTimersByTime(1000)
expect(cb1).not.toHaveBeenCalled()
})

it('schedules subsequent tasks properly if earlier tasks are cancelled', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()
const cb2 = vi.fn()

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 2000, cb2)

await Promise.resolve()

queue.cancel('key1')

// At 1000ms, process will be called because of the original timeout, but no callbacks will trigger
vi.advanceTimersByTime(1000)
expect(cb1).not.toHaveBeenCalled()
expect(cb2).not.toHaveBeenCalled()

// It should automatically schedule the next timeout for key2
vi.advanceTimersByTime(1000)
expect(cb2).toHaveBeenCalledTimes(1)
})

it('processes multiple tasks that have expired at the same time', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn()
const cb2 = vi.fn()
const cb3 = vi.fn()

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 1500, cb2)
queue.schedule('key3', 1500, cb3)

await Promise.resolve()

vi.advanceTimersByTime(1000)
expect(cb1).toHaveBeenCalledTimes(1)
expect(cb2).not.toHaveBeenCalled()

vi.advanceTimersByTime(500)
expect(cb2).toHaveBeenCalledTimes(1)
expect(cb3).toHaveBeenCalledTimes(1)
})

it('continues processing tasks if one throws an error', async () => {
const queue = CleanupQueue.getInstance()
const cb1 = vi.fn().mockImplementation(() => {
throw new Error('Test error')
})
const cb2 = vi.fn()

const spyConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

queue.schedule('key1', 1000, cb1)
queue.schedule('key2', 1000, cb2)

await Promise.resolve()

vi.advanceTimersByTime(1000)

expect(cb1).toHaveBeenCalledTimes(1)
expect(spyConsoleError).toHaveBeenCalledWith(
'Error in CleanupQueue task:',
expect.any(Error),
)
// cb2 should still be called even though cb1 threw an error
expect(cb2).toHaveBeenCalledTimes(1)

spyConsoleError.mockRestore()
})
})
Loading
Loading