The Memory state adapter stores subscriptions, locks, and cache in-process memory. Use this for local development and testing only — state is lost on restart and not shared across instances.
Never use MemoryStateAdapter in production. It logs a warning when NODE_ENV=production. Use Redis or ioredis for production deployments.
Installation
npm install @chat-adapter/state-memory
Basic Setup
import { Chat } from 'chat' ;
import { createMemoryState } from '@chat-adapter/state-memory' ;
const chat = new Chat ({
userName: 'mybot' ,
adapters: { /* ... */ },
state: createMemoryState (),
});
Configuration
createMemoryState()
Creates an in-memory state adapter. No configuration required.
const state = createMemoryState ();
// No options needed
When to Use
Local development - Fast iteration without external dependencies
Unit tests - Isolated test environments
Integration tests - Predictable state for CI/CD
Demos and prototypes - Quick proof-of-concepts
Production - State lost on restart
Serverless - Each instance has separate state
Multi-instance deployments - No shared state
Long-running subscriptions - Subscriptions lost on redeploy
How It Works
The adapter stores state in JavaScript data structures:
private readonly subscriptions = new Set < string >();
private readonly locks = new Map < string , MemoryLock >();
private readonly cache = new Map < string , CachedValue >();
Storage Lifecycle
Startup - Data structures are empty
Runtime - Subscriptions, locks, and cache accumulate in memory
Shutdown - All state is lost (cleared on disconnect)
Restart - Process starts fresh with empty state
Unlike Redis adapters, MemoryStateAdapter does NOT persist state across restarts. Users will need to re-subscribe to threads after deployment.
Thread Subscriptions
Subscriptions are stored in an in-memory Set:
chat . onNewMention ( async ( thread , message ) => {
await thread . subscribe (); // Adds threadId to Set
await thread . post ( 'Watching this thread!' );
});
chat . onSubscribedMessage ( async ( thread , message ) => {
// Only triggered if threadId is in subscriptions Set
await thread . post ( `Got: ${ message . text } ` );
});
Subscription Behavior
// Subscribe
await thread . subscribe ();
await thread . isSubscribed (); // true
// Restart bot
// <All subscriptions lost>
await thread . isSubscribed (); // false
// User must @-mention bot again to re-subscribe
Distributed Locking
Locks are stored in an in-memory Map with automatic expiration:
// Acquire lock
const lock = await state . acquireLock ( threadId , 30000 );
if ( lock ) {
try {
// Process message...
} finally {
await state . releaseLock ( lock );
}
}
Lock Cleanup
Expired locks are automatically cleaned:
private cleanExpiredLocks (): void {
const now = Date . now ();
for ( const [ threadId , lock ] of this . locks ) {
if ( lock . expiresAt <= now ) {
this . locks . delete ( threadId );
}
}
}
Memory locks work perfectly for single-instance development. In production with multiple instances, use Redis to share locks across processes.
Caching
Cache entries support optional TTL:
const state = chat . getState ();
// Store with 5-minute TTL
await state . set ( 'user:123:preferences' , { theme: 'dark' }, 5 * 60 * 1000 );
// Retrieve (checks expiration)
const prefs = await state . get < UserPreferences >( 'user:123:preferences' );
if ( prefs ) {
console . log ( `Theme: ${ prefs . theme } ` );
}
// Delete
await state . delete ( 'user:123:preferences' );
Cache Storage
interface CachedValue < T = unknown > {
value : T ;
expiresAt : number | null ; // null = no expiry
}
Expired values are lazily cleaned on access:
const cached = this . cache . get ( key );
if ( cached ?. expiresAt !== null && cached . expiresAt <= Date . now ()) {
this . cache . delete ( key ); // Expired, remove it
return null ;
}
Connection Management
The adapter implements connect() and disconnect() for interface compatibility:
const state = createMemoryState ();
await state . connect (); // No-op (logs warning in production)
// Use state...
await state . disconnect (); // Clears all data
Production Warning
When NODE_ENV=production:
[chat] MemoryStateAdapter is not recommended for production.
Consider using @chat-adapter/state-redis instead.
Testing Utilities
The adapter exposes internal methods for testing:
import { MemoryStateAdapter } from '@chat-adapter/state-memory' ;
const state = new MemoryStateAdapter ();
await state . connect ();
await state . subscribe ( 'thread-1' );
await state . subscribe ( 'thread-2' );
const count = state . _getSubscriptionCount (); // 2
const lock = await state . acquireLock ( 'thread-1' , 5000 );
const lockCount = state . _getLockCount (); // 1
await state . releaseLock ( lock ! );
state . _getLockCount (); // 0
These methods are prefixed with _ to indicate they’re for testing only. Do NOT use them in production code.
Example: Unit Testing
import { describe , it , expect , beforeEach } from 'vitest' ;
import { Chat } from 'chat' ;
import { createMemoryState } from '@chat-adapter/state-memory' ;
import { createMockAdapter } from 'chat/mock-adapter' ;
describe ( 'Subscription handling' , () => {
let chat : Chat ;
let state : ReturnType < typeof createMemoryState >;
beforeEach ( async () => {
state = createMemoryState ();
await state . connect ();
chat = new Chat ({
userName: 'testbot' ,
adapters: { test: createMockAdapter ( 'test' ) },
state ,
});
});
it ( 'subscribes to threads on mention' , async () => {
const threadId = 'test:channel:thread' ;
const isSubscribed = await state . isSubscribed ( threadId );
expect ( isSubscribed ). toBe ( false );
// Simulate subscription
await state . subscribe ( threadId );
expect ( await state . isSubscribed ( threadId )). toBe ( true );
});
it ( 'clears state on disconnect' , async () => {
await state . subscribe ( 'thread-1' );
await state . set ( 'key' , 'value' );
await state . disconnect ();
await state . connect ();
// All state cleared
expect ( await state . isSubscribed ( 'thread-1' )). toBe ( false );
expect ( await state . get ( 'key' )). toBeNull ();
});
});
Example: Integration Tests
import { describe , it , expect } from 'vitest' ;
import { createMemoryState } from '@chat-adapter/state-memory' ;
describe ( 'Lock behavior' , () => {
it ( 'prevents concurrent lock acquisition' , async () => {
const state = createMemoryState ();
await state . connect ();
const lock1 = await state . acquireLock ( 'thread-1' , 5000 );
expect ( lock1 ). not . toBeNull ();
// Second acquisition fails
const lock2 = await state . acquireLock ( 'thread-1' , 5000 );
expect ( lock2 ). toBeNull ();
// After release, can acquire again
await state . releaseLock ( lock1 ! );
const lock3 = await state . acquireLock ( 'thread-1' , 5000 );
expect ( lock3 ). not . toBeNull ();
});
it ( 'automatically expires locks' , async () => {
const state = createMemoryState ();
await state . connect ();
const lock = await state . acquireLock ( 'thread-1' , 100 ); // 100ms TTL
expect ( lock ). not . toBeNull ();
// Wait for expiration
await new Promise ( resolve => setTimeout ( resolve , 150 ));
// Can acquire again (lock expired)
const newLock = await state . acquireLock ( 'thread-1' , 5000 );
expect ( newLock ). not . toBeNull ();
});
});
Limitations
State is lost on:
Process restart
Application redeploy
Crash or unexpected shutdown
Workaround: Use Redis for production.
No cross-instance sharing
Each process/instance has isolated state:
Serverless functions don’t share subscriptions
Horizontal scaling creates separate state per replica
Load balancers route to instances with different state
Workaround: Use Redis for shared state across instances.
All state accumulates in memory:
Subscriptions never expire (until disconnect)
Cache entries with no TTL persist forever
Long-running processes can grow memory
Workaround: Periodically call disconnect() and connect() to clear, or use Redis.
No TTL background cleanup
Expired cache entries and locks are only cleaned:
On access (lazy cleanup)
When acquiring new locks
Workaround: This is fine for development. Redis handles TTL automatically.
Migration to Production
When ready for production, swap to Redis:
Set up Redis
Use a hosted provider (Vercel KV, Upstash, Railway) or run locally: docker run -d -p 6379:6379 redis:7-alpine
Install Redis adapter
npm install @chat-adapter/state-redis
npm uninstall @chat-adapter/state-memory
Update configuration
// Before (development)
import { createMemoryState } from '@chat-adapter/state-memory' ;
const state = createMemoryState ();
// After (production)
import { createRedisState } from '@chat-adapter/state-redis' ;
const state = createRedisState ({ url: process . env . REDIS_URL });
Set environment variable
export REDIS_URL = redis :// localhost : 6379
Re-subscribe threads
Existing in-memory subscriptions don’t transfer. Users must @-mention your bot again.
Environment-Based Setup
Use environment variables to switch adapters:
import { Chat } from 'chat' ;
import { createMemoryState } from '@chat-adapter/state-memory' ;
import { createRedisState } from '@chat-adapter/state-redis' ;
const state = process . env . REDIS_URL
? createRedisState ({ url: process . env . REDIS_URL })
: createMemoryState ();
const chat = new Chat ({
userName: 'mybot' ,
adapters: { /* ... */ },
state ,
});
Now:
Local development - No REDIS_URL → uses Memory
Production - REDIS_URL set → uses Redis
Debugging
View State Counts
import type { MemoryStateAdapter } from '@chat-adapter/state-memory' ;
const state = createMemoryState () as MemoryStateAdapter ;
await state . connect ();
// After some usage...
console . log ( `Subscriptions: ${ state . _getSubscriptionCount () } ` );
console . log ( `Active locks: ${ state . _getLockCount () } ` );
Reset State
// Clear all state without restarting
await state . disconnect ();
await state . connect ();
Next Steps
Redis Adapter Upgrade to production-ready Redis
State Overview Learn about subscriptions and locking
Testing Unit testing strategies
ioredis Adapter Advanced Redis with Cluster support