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.

TypeScript
// 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}
TypeScript11 lines

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 when client.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 useEffect with no return-teardown ends up with two polling loops running at once. The React adapter's own useEffect cleanup already calls client.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.
Tip

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:

TypeScript
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() }}, [])
TypeScript14 lines

For @avsbhq/node: registering custom cleanup:

TypeScript
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)})
TypeScript17 lines

For @avsbhq/react: provider teardown:

TypeScript React
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>  )}
TypeScript React14 lines

For @avsbhq/utils: CleanupRegistry directly:

CleanupRegistry is the interface; createRegistry() is the factory that builds one.

TypeScript
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()
TypeScript18 lines

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.

Was this helpful?