CleanupRegistry
CleanupRegistry is a small, ordered list of teardown functions that runs when you call client.close(). Register your own extra resources with it, like a WebSocket or a flush timer you added. They then get cleaned up in the same call, instead of being forgotten.
What it is
The SDK's own long-lived resources, its polling timer, its streaming connection, its event tracker, are torn down by dedicated code inside close(). They do not go through CleanupRegistry. The registry exists for what YOU attach. Call client.utils.registerCleanup(fn) to add your teardown to the list, and close() drains that list for you, in the order you registered each one.
// The real interface, from @avsbhq/utilsinterface CleanupRegistry { // Register a teardown. The function it returns both unregisters // AND immediately runs that one teardown. add(teardown: () => void): () => void // Runs every remaining teardown, in registration order. Synchronous: // it does not wait for a teardown that starts async work. drain(): void // Pending teardown count, useful in tests. size(): number}Every teardown is wrapped in its own try/catch, so one throwing does not stop the rest from running.
When to use it
You rarely touch CleanupRegistry directly. It matters in one real scenario:
- Custom integrations. You attach a listener or a sink with its own teardown, like an extra WebSocket or a flush timer you started yourself. Call
client.utils.registerCleanup(fn)so it tears down whenclient.close()runs, instead of leaking.
A framework middleware adapter, like @avsbhq/utils/middleware/express, does not use CleanupRegistry. It scopes a request-bound client through Node's AsyncLocalStorage instead, so there is nothing here for it to register.
Why this matters
Three environments make a forgotten client.close() acutely painful:
- Node.js serverless (Lambda, Vercel Functions). A warm function instance is reused across invocations. A client you never close keeps its polling timer running, and it accumulates across invocations until you see stale-data bugs or CPU spikes.
- React Strict Mode. Development mode mounts and unmounts every component twice, to surface missing cleanup. An SDK client created in a
useEffectwith no return-teardown ends up with two polling loops running at once. The React adapter's ownuseEffectcleanup already callsclient.close()for you. - Test suites. Jest and Vitest keep the Node.js process alive between test files. A client left open in one test can still fire its polling timer during another test and mutate state you don't expect.
Always call client.close() in your framework's cleanup hook. For React, that means returning it from useEffect. For Express or Fastify, call it during SIGTERM handling, before the process exits.
How it works in practice
For @avsbhq/browser: React cleanup:
import { useEffect, useState } from 'react'import { AvsbClient } from '@avsbhq/browser'// In your provider componentconst [client, setClient] = useState<AvsbClient | null>(null)useEffect(() => { const created = new AvsbClient({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx' }) created.onReady().then(() => setClient(created)) // close() tears down the client's own polling and streaming directly, // then drains anything you registered with registerCleanup(). return () => { created.close() }}, [])For @avsbhq/node: registering custom cleanup:
import { AvsbServer } from '@avsbhq/node'const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })await server.onReady()// Your own resourcedeclare function myBatchFlush(): voidconst flushInterval = setInterval(() => myBatchFlush(), 10_000)// Register teardown so client.close() clears it tooserver.utils.registerCleanup(() => clearInterval(flushInterval))// On SIGTERM / graceful shutdownprocess.on('SIGTERM', async () => { await server.close() // its own polling and tracker, plus your flush timer process.exit(0)})For @avsbhq/react: provider teardown:
import { AvsbProvider } from '@avsbhq/react'// AvsbProvider calls client.close() in its own useEffect cleanup// You do not need to call it manually when using the providerfunction App() { return ( <AvsbProvider sdkKey="sdk_production_xxxxxxxxxxxxxxxx" context={{ kind: 'user', key: 'u_1' }} > <YourApp /> </AvsbProvider> )}For @avsbhq/utils: CleanupRegistry directly:
CleanupRegistry is the interface; createRegistry() is the factory that builds one.
import { createRegistry } from '@avsbhq/utils'import type { CleanupRegistry } from '@avsbhq/utils'// Use the registry standalone to manage any set of teardownsconst registry: CleanupRegistry = createRegistry()declare const timerA: ReturnType<typeof setInterval>declare const ws: { close(): Promise<void> }const unregisterA = registry.add(() => clearInterval(timerA))const unregisterB = registry.add(() => { void ws.close() })// Calling the returned function both unregisters this one teardown// AND runs it immediately, ahead of drain().unregisterA()// Runs everything still pending (unregisterB's ws.close(), here).registry.drain()drain() is synchronous: it calls every remaining teardown and returns right away. A teardown that starts async work (closing a socket, flushing a buffer) is not waited on, so await it yourself first if the order matters.
Related concepts
- Decision Logging: the decision recorder runs its own flush timer and its own
close(). It's a separate component, not registered into any SDK'sCleanupRegistry, so you close it yourself. - Typed Contexts: CleanupRegistry ships alongside defineContextSchema in @avsbhq/utils
- SDK Installation: onReady and close lifecycle overview