Async batching keeps the collection and trigger behavior described in the Batching Guide, while adding Promise results, retries, error callbacks, failed-item tracking, and control over in-flight work.
Use it when one async operation should process several collected items together. Use an Async Queue when each item needs its own execution or when you need to limit concurrency.
Items collect until any configured trigger fires:
add A ─── add B ─── add C
│ │ │
└─ wait reset └─ maxSize reached
│
└─ execute [A, B, C]add A ─── add B ─── add C
│ │ │
└─ wait reset └─ maxSize reached
│
└─ execute [A, B, C]A batch executes when:
Both maxSize and wait default to Infinity, so configure at least one trigger or call flush() manually. The wait timer restarts on every addition. It measures a quiet period, not a maximum age for the oldest item.
import { useAsyncBatcher } from '@tanstack/preact-pacer'
function AnalyticsButton() {
const batcher = useAsyncBatcher(
sendEvents,
{ maxSize: 20, wait: 1000 },
(state) => ({
size: state.size,
isExecuting: state.isExecuting,
}),
)
return (
<button onClick={() => void batcher.addItem({ type: 'click' })}>
Track ({batcher.state.size} pending)
</button>
)
}import { useAsyncBatcher } from '@tanstack/preact-pacer'
function AnalyticsButton() {
const batcher = useAsyncBatcher(
sendEvents,
{ maxSize: 20, wait: 1000 },
(state) => ({
size: state.size,
isExecuting: state.isExecuting,
}),
)
return (
<button onClick={() => void batcher.addItem({ type: 'click' })}>
Track ({batcher.state.size} pending)
</button>
)
}The focused snippets later in this guide use useAsyncBatcher and assume they run inside a component or another hook.
flush() returns the batch function's result and is the clearest way to await a specific batch.
addItem() also returns a Promise, but it should not be treated as an individual item's result receipt:
Use onSuccess, onError, and onSettled when all additions need to observe the eventual batch outcome.
The batcher copies and clears the current items before calling the async function. Items added while that function is active collect in a new batch:
execute [A, B] ───────────────── finish
add C ─── add D ─── execute [C, D] ─── finishexecute [A, B] ───────────────── finish
add C ─── add D ─── execute [C, D] ─── finishIf the second batch's trigger fires before the first finishes, both batch functions can overlap. useAsyncBatcher does not have a concurrency option. Serialize batch executions outside the batcher or send the completed batches through an async queue when overlap is unsafe.
Async batchers provide these callbacks:
Without onError, throwOnError defaults to true, so flush() or a size-triggering addItem() rejects on failure. Providing onError changes that default to false; the Promise then resolves with undefined.
Items are removed from the pending collection before execution. A failed batch is not automatically requeued. Its items are added to failedItems and are available through peekFailedItems() until clear() or a later execution clears that collection.
const failed = batcher.peekFailedItems()
for (const item of failed) {
saveForManualRecovery(item)
}const failed = batcher.peekFailedItems()
for (const item of failed) {
saveForManualRecovery(item)
}For non-idempotent operations, verify the server outcome before resubmitting a failed batch.
Configure retries for each batch execution with asyncRetryerOptions:
const batcher = useAsyncBatcher(sendEvents, {
maxSize: 20,
wait: 1000,
asyncRetryerOptions: {
maxAttempts: 3,
backoff: 'exponential',
baseWait: 500,
jitter: 0.2,
},
})const batcher = useAsyncBatcher(sendEvents, {
maxSize: 20,
wait: 1000,
asyncRetryerOptions: {
maxAttempts: 3,
backoff: 'exponential',
baseWait: 500,
jitter: 0.2,
},
})maxAttempts includes the first attempt, and every retry receives the same copied batch. See the Async Retrying Guide before retrying operations with side effects.
Because clear() leaves the timer in place, use cancel() followed by clear() when no empty timer should remain:
batcher.cancel()
batcher.clear()batcher.cancel()
batcher.clear()The batch function is not called when an eventual timer or flush() finds no items.
abort() aborts active retryers. It does not cancel a pending batch or remove collected items. Pass the batcher's signal to the underlying API for cancellation to propagate:
const batcher = useAsyncBatcher(
async (events: Array<AnalyticsEvent>) => {
return fetch('/api/analytics/batch', {
method: 'POST',
body: JSON.stringify(events),
signal: batcher.getAbortSignal() ?? undefined,
})
},
{ maxSize: 20, wait: 1000 },
)
batcher.abort()const batcher = useAsyncBatcher(
async (events: Array<AnalyticsEvent>) => {
return fetch('/api/analytics/batch', {
method: 'POST',
body: JSON.stringify(events),
signal: batcher.getAbortSignal() ?? undefined,
})
},
{ maxSize: 20, wait: 1000 },
)
batcher.abort()When executions overlap, pass an executeCount to getAbortSignal() when you need a specific execution's signal.
reset() restores default state, but it does not clear a scheduled timer or guarantee that active underlying work stops. Use the lifecycle methods first when a complete cleanup is required:
batcher.cancel()
batcher.abort()
batcher.reset()batcher.cancel()
batcher.abort()
batcher.reset()The adapter cancels the pending wait timer and aborts active work when its owner is destroyed. Providing onUnmount replaces that default cleanup, so a custom callback must perform every required lifecycle action. When custom cleanup flushes work, remember that user callbacks can run while the component is being destroyed.
The adapter subscribes only to the state returned by the selector argument. Without a selector, the adapter state is empty. Create the utility at the top level of a component or another hook and select only fields used by the view:
const batcher = useAsyncBatcher(
sendEvents,
{ maxSize: 20, wait: 1000 },
(state) => ({
size: state.size,
isExecuting: state.isExecuting,
failedItems: state.failedItems,
}),
)
console.log(
batcher.state.size,
batcher.state.isExecuting,
batcher.state.failedItems,
)const batcher = useAsyncBatcher(
sendEvents,
{ maxSize: 20, wait: 1000 },
(state) => ({
size: state.size,
isExecuting: state.isExecuting,
failedItems: state.failedItems,
}),
)
console.log(
batcher.state.size,
batcher.state.isExecuting,
batcher.state.failedItems,
)Option functions and lifecycle callbacks receive the underlying public utility instance. The .store.state reads inside those callbacks in the examples above are supported. Rendering code should read the selected adapter state shown here.
wait may be a number or a function that receives the batcher instance. setOptions() merges new options, and asyncBatcherOptions() creates reusable, type-checked option objects.
Do not use started to pause a batcher. It is currently a no-op, so every addItem() call evaluates the configured triggers.
To restore selected state that your app has persisted, pass a partial snapshot through initialState. It is merged with the defaults. Restore only durable fields. Pending timers and active executions are not restored.
Common state includes:
See the Preact API reference for adapter signatures and the public core reference for complete option and state types.