Database adapters
NotifyKit stores all state (recipients, notifications, inbox items, deliveries, preferences) in your database via a pluggable adapter. The memory adapter is great for development; Drizzle adapters provide persistent SQLite and PostgreSQL storage.
Which adapter?
Memory
Dev & tests. Zero config, instant startup, resets on restart. No database required — state lives in-process.
| Persistence | None (in-process) |
| Concurrency | Single process |
| Setup | Zero — built into core |
SQLite
Prototypes & single-server. File-based persistence without a running database process. Fast reads, simple deploys.
| Persistence | File on disk |
| Concurrency | Single writer |
| Setup | better-sqlite3 |
PostgreSQL
Persistent multi-instance deployments. Supports concurrent writers and uses your existing Postgres operations and backups.
| Persistence | Networked DB |
| Concurrency | Multi-writer |
| Setup | postgres + connection URL |
lib/notifykit.ts. All behavior stays identical — only the storage layer changes.notify.send()All reads and writes go through the adapter interface — NotifyKit never touches your DB directly.
The adapter maps operations (create inbox item, update preference) to your ORM or raw SQL.
Same connection pool, same backup strategy, same access controls as the rest of your app.
Memory adapter
Zero-config, no database required. State lives in-process and resets on restart. Perfect for local dev and tests.
import { memoryAdapter } from "@notifykitjs/core"
const notify = createNotifyKit({
// ...
database: memoryAdapter(),
})Drizzle SQLite
npm install @notifykitjs/drizzle drizzle-orm better-sqlite3import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import { createSqliteTables, drizzleSqliteAdapter } from "@notifykitjs/drizzle"
const sqlite = new Database("app.db")
const db = drizzle(sqlite)
// Create tables (run once, or use drizzle-kit migrations in production)
await createSqliteTables(db)
export const notify = createNotifyKit({
// ...
database: drizzleSqliteAdapter(db),
})Drizzle PostgreSQL
npm install @notifykitjs/drizzle drizzle-orm postgresimport postgres from "postgres"
import { drizzle } from "drizzle-orm/postgres-js"
import { createPgTables, drizzlePostgresAdapter } from "@notifykitjs/drizzle"
const client = postgres(process.env.DATABASE_URL!)
const db = drizzle(client)
// Create tables (run once, or use drizzle-kit migrations)
await createPgTables(db)
export const notify = createNotifyKit({
// ...
database: drizzlePostgresAdapter(db),
})Connection pool sizing
NotifyKit uses your existing database connection — it doesn't open its own pool. But notification sends add database load, especially under burst traffic. Size the pool from measurements in your deployment, not a generic sends-per-second table.
| Signal | What to inspect | Possible response |
|---|---|---|
| Connections wait during send bursts | Pool wait time and pg_stat_activity | Reduce concurrency, increase the pool within database limits, or queue work |
| Many application instances exhaust total connections | Aggregate pool size across every instance | Use a pooler and set a smaller per-instance maximum |
| Queries are slow without pool waits | EXPLAIN ANALYZE, locks, and database I/O | Tune the query/index or reduce contention before adding connections |
// postgres.js — set pool size explicitly
const client = postgres(process.env.DATABASE_URL!, {
max: 10, // connections in the pool
idle_timeout: 20, // seconds before idle connections close
connect_timeout: 10, // seconds to wait for a connection
})Production migrations
The createPgTables / createSqliteTables helpers are fine for quick setup. For production, export the schema and use drizzle-kit for versioned migrations:
# Generate a migration from the NotifyKit schema
npx drizzle-kit generate
# Apply it (same as your app migrations)
npx drizzle-kit migrateJoining NotifyKit tables with your app
The Drizzle adapter exports the full schema so you can join NotifyKit tables against your own:
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { eq } from "drizzle-orm"
// Get all inbox items for a user with their notification details
const items = await db
.select()
.from(notifyKitSchema.inboxItems)
.where(eq(notifyKitSchema.inboxItems.recipientId, user.id))
.orderBy(notifyKitSchema.inboxItems.createdAt)Common queries
Patterns you'll likely need when building admin dashboards, analytics, or custom UI on top of NotifyKit data:
| Query | Use case |
|---|---|
| Unread count per user | Dashboard badges, nav indicators outside React hooks |
| Failed deliveries in last hour | Incident detection, provider health checks |
| Recipients with no recent inbox items | Churn detection, stale account cleanup |
| Delivery success rate by channel | Provider monitoring, cost/reliability analysis |
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { eq, isNull, gt, sql, and, count } from "drizzle-orm"
const { inboxItems, deliveries, recipients } = notifyKitSchema
// Unread count for a specific user (server-side, outside hooks)
const [{ unread }] = await db
.select({ unread: count() })
.from(inboxItems)
.where(and(
eq(inboxItems.recipientId, userId),
isNull(inboxItems.readAt),
isNull(inboxItems.archivedAt),
))
// Failed deliveries in the last hour (incident detection)
const oneHourAgo = new Date(Date.now() - 60 * 60_000)
const failures = await db
.select()
.from(deliveries)
.where(and(
eq(deliveries.status, "failed"),
gt(deliveries.failedAt, oneHourAgo),
))
// Delivery success rate by channel (last 24h)
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60_000)
const stats = await db
.select({
channel: deliveries.channel,
total: count(),
sent: sql`count(*) filter (where ${deliveries.status} = 'sent')`,
})
.from(deliveries)
.where(gt(deliveries.createdAt, oneDayAgo))
.groupBy(deliveries.channel)Schema overview
| Table | Growth | Retention strategy |
|---|---|---|
notifykit_recipients | 1 row per user (stable) | None needed — grows with user base only |
notifykit_notifications | 1 row per send (linear) | Archive or delete after 90 days for analytics-only data |
notifykit_inbox_items | 1 row per send × inbox channel | Users delete via UI. Optionally purge archived items older than 30 days. |
notifykit_deliveries | 1 row per channel per send | Useful for debugging — prune status = 'sent' after 30 days, keep failures longer |
notifykit_preferences | 1 row per (user, notification) pair | None needed — bounded by user count × notification count |
notifykit_scheduled_sends | Transient — consumed on flush | Self-cleaning. Rows disappear after quiet hours end. |
notifykit_rate_limit_events | Transient — expires per window | Self-cleaning. Purge events older than your longest window. |
notifykit_digest_buffers | Transient — consumed on digest flush | Self-cleaning. Rows disappear after digest window fires. |
notifykit_timeline_events | 3–8 rows per send (fastest grower) | pruneTimeline() on a cron — set timelineRetentionMs |
timelineRetentionMs to 7–14 days and run pruneTimeline() on a daily cron. Without pruning, a 100 sends/day app accumulates ~20k rows/month in this table alone.Data retention automation
The schema overview above tells you what to prune — here's the how. Set up a daily cron job that cleans up old records without affecting active data:
// Run daily: npx tsx scripts/prune-notifykit.ts
// Or schedule via cron: 0 3 * * * npx tsx scripts/prune-notifykit.ts
import { notify } from "@/lib/notifykit"
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { lt, and, eq, isNotNull } from "drizzle-orm"
import { db } from "@/lib/db"
const { deliveries, inboxItems, timelineEvents } = notifyKitSchema
const RETENTION = {
timeline: 14, // days — fastest-growing table
sentDeliveries: 30, // days — keep failures longer
archivedInbox: 30, // days — user already dismissed these
}
async function prune() {
const now = Date.now()
// 1. Timeline events (3-8 rows per send — biggest growth)
const timelineCutoff = new Date(now - RETENTION.timeline * 86_400_000)
const timelineResult = await db
.delete(timelineEvents)
.where(lt(timelineEvents.createdAt, timelineCutoff))
console.log(`Timeline: pruned ${timelineResult.rowCount ?? 0} rows (>${RETENTION.timeline}d)`)
// 2. Successful deliveries (keep failures for debugging)
const deliveryCutoff = new Date(now - RETENTION.sentDeliveries * 86_400_000)
const deliveryResult = await db
.delete(deliveries)
.where(and(
eq(deliveries.status, "sent"),
lt(deliveries.createdAt, deliveryCutoff),
))
console.log(`Deliveries (sent): pruned ${deliveryResult.rowCount ?? 0} rows (>${RETENTION.sentDeliveries}d)`)
// 3. Archived inbox items (user already read + archived)
const archiveCutoff = new Date(now - RETENTION.archivedInbox * 86_400_000)
const archiveResult = await db
.delete(inboxItems)
.where(and(
isNotNull(inboxItems.archivedAt),
lt(inboxItems.archivedAt, archiveCutoff),
))
console.log(`Archived inbox: pruned ${archiveResult.rowCount ?? 0} rows (>${RETENTION.archivedInbox}d)`)
}
prune().catch(err => {
console.error("Prune failed:", err)
process.exit(1)
})| Table | What gets pruned | Safe because |
|---|---|---|
timeline_events | All events older than 14 days | Timeline is for debugging — old events have no runtime effect |
deliveries | Only status: "sent" older than 30 days | Failed deliveries stay for investigation; sent records are just audit trail |
inbox_items | Only archived items older than 30 days | User already dismissed them; active/unread items are never touched |
LIMIT 10000 in a loop to avoid long-running transactions.Custom adapter
Implement the DatabaseAdapter interface to use any database (Prisma, Kysely, raw SQL, DynamoDB, etc). The interface is split into logical sections:
| Section | Methods | Priority |
|---|---|---|
| Recipients | upsert, findById | Required |
| Notifications | create, findByIdempotencyKey, clearIdempotencyKey | Required |
| Inbox | create, list/count, recipient-safe mutations | Required |
| Deliveries | create, findById, list, update | Required |
| Preferences | get, list, upsert | Required |
| Rate limits | reserve, count | Required; reservation must be atomic |
| Digests | append, take, restore, list | Required |
| Scheduled | create, claim, complete, release, list methods | Required |
| Dedupe | check, exists, prune | Required; check-and-insert must be atomic |
| Timeline | append, list, prune methods | Optional; enables persisted diagnostics |
Implementation approach
Import DatabaseAdapter and implement its nested stores: recipients, notifications, inbox, deliveries, preferences, digests, rateLimits, scheduledSends, and dedupe. The optional timeline store enables persisted diagnostics. TypeScript reports every missing method and its exact input/result contract.
import type { DatabaseAdapter } from "@notifykitjs/core"
export function customAdapter(): DatabaseAdapter {
return {
recipients: createRecipientStore(),
notifications: createNotificationStore(),
inbox: createInboxStore(),
deliveries: createDeliveryStore(),
preferences: createPreferenceStore(),
digests: createDigestStore(),
rateLimits: createRateLimitStore(),
scheduledSends: createScheduledSendStore(),
dedupe: createDedupeStore(),
timeline: createTimelineStore(), // optional
}
}memoryAdapter() for the simplest complete contract, or the SQLite/Postgres adapter matching your concurrency needs. Atomic dedupe, rate-limit reservations, digest take/restore, and scheduled-send claims are correctness boundaries.Implementation tips
| Concern | Guidance |
|---|---|
| ID generation | Adapter create methods receive inputs without generated IDs/timestamps. Generate them consistently with the reference adapters. |
| Scope filtering | Apply every supplied tenantId and workspaceId on reads and mutations. Scope handling is a security boundary. |
| Atomic operations | Implement dedupe.check, rateLimits.reserve, digests.take, and scheduledSends.claim atomically across workers. |
| Failure recovery | Digest restore must preserve payload order, and scheduled sends must be completed only after delivery succeeds. |
| Testing | Run the same behavioral scenarios as memoryAdapter(), including concurrency, tenant isolation, and claim/release recovery. |
Switching adapters by environment
A common pattern: use memory for tests (fast, isolated, no cleanup), SQLite for local dev (persistent between restarts), and Postgres in production.
import { memoryAdapter } from "@notifykitjs/core"
import { drizzleSqliteAdapter } from "@notifykitjs/drizzle"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
function getAdapter() {
if (process.env.NODE_ENV === "test") return memoryAdapter()
if (process.env.DATABASE_URL) return drizzlePostgresAdapter(pgDb)
return drizzleSqliteAdapter(sqliteDb)
}
export const notify = createNotifyKit({
notifications: [...] as const,
database: getAdapter(),
})memoryAdapter() with zero state — no cleanup needed between runs.Performance at scale
NotifyKit queries are straightforward — but as volume grows, certain access patterns become slow without proper indexing or query tuning. Use this table to diagnose common performance symptoms:
| Symptom | Likely cause | Fix |
|---|---|---|
| Inbox list slow for high-volume users | Missing composite index on (recipientId, archivedAt, createdAt) | Add the index — the Drizzle adapter creates it by default, but custom adapters may not |
send() latency spikes during broadcasts | Connection pool exhausted — too many concurrent writes | Batch with Promise.allSettled in chunks of 10–20, or use an external queue |
flushDigests() slows as the buffer grows | Large digest buffer table with no index on expiresAt | Add index on (expiresAt) — the flush query scans by expiry time |
unreadCount() regresses as inbox history grows | Table scan on inbox_items for users with 1000+ items | The default index covers this, but verify with EXPLAIN ANALYZE |
| Timeline queries slow after months of data | Timeline table grew unbounded — no pruning configured | Run pruneTimeline() on a cron and set timelineRetentionMs |
| Preference resolution dominates send latency | Multiple round-trips for global + category + notification preferences | The Drizzle adapter fetches all preferences for a recipient in one query — verify your custom adapter does the same |
Key indexes for PostgreSQL
The Drizzle adapter creates these automatically. If you're using a custom adapter, verify they exist:
-- Inbox queries (list by recipient, filter archived, sort by time)
CREATE INDEX idx_inbox_recipient_active
ON notifykit_inbox_items (recipient_id, created_at DESC)
WHERE archived_at IS NULL;
-- Delivery queries (find failures for incident investigation)
CREATE INDEX idx_deliveries_status_time
ON notifykit_deliveries (status, created_at DESC)
WHERE status = 'failed';
-- Timeline pruning (prune by age without full table scan)
CREATE INDEX idx_timeline_created
ON notifykit_timeline_events (created_at);
-- Rate limit counting (sliding window lookups)
CREATE INDEX idx_rate_limits_key_time
ON notifykit_rate_limit_events (key, created_at);EXPLAIN ANALYZE on slow queries before adding indexes. Establish a baseline with your data volume and alert on meaningful regression against that baseline.Migration checklist
Moving from memory → production adapter? Walk through this:
| Step | Action | Verify |
|---|---|---|
| 1 | Install @notifykitjs/drizzle + your driver | npm ls @notifykitjs/drizzle shows it installed |
| 2 | Run createPgTables(db) or generate a migration | All notifykit_* tables exist in your database |
| 3 | Swap memoryAdapter() for the Drizzle adapter | App boots without errors |
| 4 | Send a test notification | Row appears in notifykit_notifications |
| 5 | Open the inbox UI | Item renders — confirms reads work too |
upsertRecipient() and preference writes.