Optimize performance
Measure where Better IAM spends time, then keep authorization checks, storage lookups, and background work fast with batching, query plans, indexes, retention, and offline token verification.
Better IAM answers every authorization check from the current state in your database, with no permission cache, so its speed depends on how many transactions you ask it to run and how quickly your database answers them. This guide shows how to measure that, then how to cut the work down. You will:
- measure IAM latency with spans, Prometheus metrics, and an instrumented store;
- keep storage lookups on their indexes and keep tables small with the retention jobs;
- replace loops of
authorizecalls with oneauthorizeMany,listAccessible, orplanResourcescall; - let downstream services verify callers without calling IAM;
- keep email, webhooks, and cleanup off the request path;
- tune the adapter's lock and pool settings.
Better IAM has no decision cache to turn on. The only way to skip IAM on a request is a signed token that a downstream service verifies offline (step 5), and that trades away revocation until the token expires.
Prerequisites
- A running Better IAM instance on PostgreSQL, SQLite, or libSQL. See Databases.
- The scheduled jobs in place, or a plan to add them.
- Familiarity with
iam.authorizeandiam.require.
How a request spends time
Read this section first: it explains why the steps below help.
| Mechanism | What it means for performance |
|---|---|
| Serialized transactions | Every IAM transaction, reads included, takes one lock on the database: a transaction-scoped advisory lock on PostgreSQL (shared by every instance), BEGIN IMMEDIATE on SQLite and local libSQL. Authorization checks, session validation, and writes all run inside such a transaction, so IAM transactions on one database run one at a time. Short transactions and few round trips matter more than CPU. |
| No permission cache | Token validity, roles, and policies are read on every use, so a revocation applies on the next request everywhere. There is nothing to warm or invalidate. |
| Query pushdown | find() filters become typed SQL conditions (up to 24 per query), and results are paged in SQL when the whole filter can be expressed there. Array and IP conditions, and values a database cannot compare, are filtered in memory. |
| Indexed lookup fields | Hot fields (INDEXED_FIELDS in better-iam/core: session token hashes, identity, group, and role IDs, email addresses, OAuth artifact hashes, and more) have indexes: one partial expression index per field on SQLite and libSQL, one jsonb_path_ops GIN index over the document on PostgreSQL. |
| Ordered reads | Audit listings, exports, and pruning page by timestamp or sequence in SQL (findOrdered), so their cost follows the page size, not the length of the log. |
| Keyset paging | Bulk walks (snapshots, the retention sweep) page with an after cursor on the record ID, so each page costs the same however deep it is. |
| Throttled session touches | Validating a session writes its lastSeenAt at most once a minute, or once per tenth of the idle timeout when that is shorter. |
| Short bounded caches | Each process reuses a tenant's network blocks for five seconds, and spend-budget standings for up to 30 seconds. No authorization decision, token, role, or policy is cached across requests. |
| Buffered usage counting | With accessUsage, used actions are counted in memory and written in batches (every minute by default, or at 10000 buffered pairs). |
| Outbox | Email, SMS, webhooks, and event subscribers never run inside a request. They wait in the database until a job delivers them. |
See Data and consistency for why the design favors consistent revocation over write throughput.
Measure before you change anything
Three tools report where time goes. Spans time each unit of work, the metrics collector aggregates them, and an instrumented store times each storage call.
import { betterIam } from 'better-iam';
import { instrumentStore } from 'better-iam/core';
import { postgresAdapter } from 'better-iam/adapter-postgres';
const database = instrumentStore(
postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
(call) => {
// call: { method, collection, filterKeys, page, records, durationMs, inTransaction, failed }
if (call.durationMs > 50) console.warn('slow IAM storage call', call);
},
);
export const iam = betterIam({
database,
secret: process.env.BETTER_IAM_SECRET!,
baseURL: 'https://identity.example.com',
observability: {
// Prometheus counters and histograms, scraped at GET /api/iam/metrics.
metrics: { bearerToken: process.env.METRICS_TOKEN },
onSpan(span) {
// kind: 'operation' | 'authorize' | 'authorizeMany' | 'listAccessible' | 'auth' | 'http'
if (span.durationMs > 250) console.warn('slow IAM span', span.kind, span.name, span.durationMs);
},
},
});better_iam_span_duration_secondsis a histogram bykind, andbetter_iam_spans_totalcounts spans bykind,name,outcome, andcode. See Prometheus metrics.instrumentStorereports filter keys, never values, so its output is safe to log. It reports a keyset cursor aspage.keyset: true. See Measuring.- In the repository,
pnpm bench:scaleseedsBENCH_IDENTITIESidentities (default 5000) and prints per-operation latency and storage calls overBENCH_ITERATIONSruns (default 40).
Many storage calls per span usually means a loop that one batch call can replace (step 3). A few slow calls usually means a missing migration, a stale PostgreSQL index, or a table the retention jobs should have trimmed (step 2).
Keep storage on its indexes and its tables small
The indexes come from migrations, and the retention jobs keep the indexed tables from growing with traffic.
- Apply every migration. Lookup indexes ship as schema steps (
0002_query_indexesthrough0006_protection_indexes).better-iam doctorreportsschema-behindwhen one is missing. See Migrations. - Vacuum after bulk imports on PostgreSQL. Rows written after the GIN index exists wait in its pending list until
a vacuum merges them. After an import or a
store-import, runVACUUM ANALYZE iam_records. - Run
purgeandsweep. Expired sessions, OAuth and SAML artifacts, and delivered outbox rows stay in their collections until the sweep deletes them, anddispatchOutboxreads the whole outbox. See Retention sweep. - Keep transactions short in your own code. Never make network calls inside
iam.store.transaction()or a plugin transaction: other IAM transactions wait for the lock meanwhile. - Know the libSQL write cost.
@libsql/clientcompiles each statement on every call, so local libSQL writes cost more than the SQLite adapter's, which caches prepared statements.
better-iam migrate --config better-iam.config.mjs
psql "$DATABASE_URL" -c 'VACUUM ANALYZE iam_records'
better-iam sweep --config better-iam.config.mjsBatch authorization checks
Each iam.authorize call is its own transaction, so ten checks wait for the lock ten times. Batch calls prepare the
caller's state once and answer everything in one transaction.
import { iam } from '@/lib/iam';
export async function toolbarActions(headers: Headers, tenantId: string, documentId: string) {
const resource = { type: 'document', id: documentId };
// One transaction, one consistent view of the configuration, up to 50 checks.
const { results } = await iam.authorizeMany({
headers,
tenantId,
checks: [
{ action: 'documents:write', resource },
{ action: 'documents:share', resource },
{ action: 'documents:delete', resource },
],
});
return Object.fromEntries(results.map((result) => [result.action, result.allowed]));
}| Instead of | Use | Limits |
|---|---|---|
authorize in a loop over buttons or rows you already loaded | iam.authorizeMany | 1 to 50 checks for one tenant; more is INVALID_INPUT |
authorize per registered resource to build a list | iam.listAccessible | Managed resource types only; limit up to 1000 per page |
Fetching rows, then authorize per row | iam.planResources and a compiled filter (step 4) | Any resource type |
resources.register in a loop | iam.api.resources.registerMany | Up to 100 resources per call |
listAccessible loads the caller's grants and boundaries once and evaluates every registration of the type against
them, so its cost does not multiply by the number of grants. Batch results are advisory: still call iam.require
immediately before the protected operation. See Batches and reverse queries.
Route: POST /resources/registerMany
Filter lists in your own database
For a list page over your own table, ask for a query plan once and let your database do the filtering, sorting, and paging.
import { filterToSql } from 'better-iam/core';
import { iam } from '@/lib/iam';
import { db } from '@/lib/db';
const columns: Record<string, string> = { id: 'd.id', ownerId: 'd.owner_id' };
export async function readableDocuments(headers: Headers, tenantId: string) {
const plan = await iam.planResources({ headers, tenantId, action: 'documents:read', type: 'document' });
if (plan.kind === 'never') return [];
const where = filterToSql(plan.filter, {
dialect: 'postgres',
column: (field) => columns[field],
offset: 1, // $1 is the tenant
});
return db.query(`SELECT * FROM documents d WHERE d.tenant_id = $1 AND ${where.sql} LIMIT 50`, [
tenantId,
...where.params,
]);
}planResources needs no permission, is not audited, and runs once per page instead of once per row. A filter a target
cannot express refuses with UNSUPPORTED_FILTER; then fall back to authorizeMany over candidate rows. See
Data filtering.
Route: POST /filters/plan
Verify callers downstream without calling IAM
Services behind your application do not need to ask IAM who is calling on every request. Two token types are signed by IAM and verified offline:
| Token | Issued by | Verified with | Revocable before expiry |
|---|---|---|---|
| Stateless assertion (HS256) | iam.api.assertions.issue | verifyAssertion from better-iam with iam.assertionKey() | No; lifetime 10 seconds to one hour, five minutes by default |
| Session JWT (EdDSA or ES256) | iam.api.sts.getSessionToken with format: 'jwt' (needs sts.jwt) | createSessionTokenVerifier from better-iam/session-tokens, keys from GET /api/iam/.well-known/jwks.json | No; at most sts.jwt.maxLifetimeSeconds (one hour by default) |
import { verifyAssertion } from 'better-iam';
// Holds only the derived key, never the deployment secret.
export function caller(token: string) {
return verifyAssertion(token, { key: process.env.IAM_ASSERTION_KEY!, audience: 'reports' });
}The trade-off is revocation: a verified token stays valid until it expires, so keep lifetimes short. Use this for
reads that tolerate that window, and keep iam.require for mutations. See
stateless assertions and
Session tokens.
Route: POST /assertions/issue
Keep slow work off the request path
Nothing in a request waits on a mail provider, a webhook endpoint, or a cleanup pass. Make sure the jobs that do that work run often enough and in the right process.
- Deliveries and event subscribers run only when
iam.auth.dispatchOutbox()andiam.events.dispatch()run.dispatchOutbox(limit?)sends up tolimitmessages per call (default 100, at most 1000), each claimed in its own short transaction, so several workers never send the same message. - The retention sweep and audit archiving run in short batches (
sweepExpiredusesbatchSize, 500 by default), so each holds the lock briefly. - With
accessUsage, counts are written in the background; calliam.flushAccessUsage()before shutdown.
import { iam } from './lib/iam';
setInterval(async () => {
await iam.auth.dispatchOutbox(500);
await iam.events.dispatch();
}, 60_000);
process.on('SIGTERM', async () => {
await iam.flushAccessUsage();
process.exit(0);
});See Scheduled jobs for every job and its cadence.
Page large reads
Read big result sets in pages whose cost does not grow with depth.
audit.exportfollows the chain bysequence: start atfromSequence, follownextSequence, up to 10000 events per page.audit.listtakesfromandto(epoch milliseconds). Narrow by time instead of using a deepoffset.- List methods such as
identities.listandlistAccessibletakelimitandoffset. - In plugin or adapter code,
store.find(collection, filter, { after, limit })pages by record ID (keyset), andfindOrdered(store, collection, filter, order)frombetter-iam/corepages by a numeric field in SQL.
import { appendFile } from 'node:fs/promises';
import { iam } from '../lib/iam';
export async function exportAudit(credential: { token: string }, tenantId: string, file: string) {
let fromSequence: number | undefined = 1;
while (fromSequence !== undefined) {
const page = await iam.api.audit.export(credential, { tenantId, fromSequence, limit: 5000 });
if (page.count) await appendFile(file, `${page.body}\n`);
fromSequence = page.nextSequence;
}
}Route: POST /audit/export
Tune the adapter
The adapter options decide how long a transaction waits for the lock before it fails with STORAGE_BUSY (503).
import { postgresAdapter } from 'better-iam/adapter-postgres';
export const database = postgresAdapter({
connectionString: process.env.DATABASE_URL!,
poolSize: 10, // default 10
lockTimeoutMs: 5000, // default 5000: the wait for the IAM lock
});Put the database close to the application: every transaction holds the lock for at least its round trips. On SQLite,
durability: 'normal' in WAL mode makes commits cheaper but can lose the last commits on power loss (never the
database); keep 'full' unless you accept that.
Options
| Option | Type | Default | Description |
|---|---|---|---|
postgresAdapter({ poolSize }) | number | 10 | Connections in the PostgreSQL pool. |
postgresAdapter({ lockTimeoutMs }) | number | 5000 | How long a transaction waits for the IAM advisory lock before STORAGE_BUSY. |
sqliteAdapter({ busyTimeoutMs }) | number | 5000 | How long SQLite waits for the writer lock. |
libsqlAdapter({ busyTimeoutMs }) | number | 5000 | How long local libSQL waits for the writer lock. |
sqliteAdapter({ durability }) | 'full' | 'normal' | 'full' | normal makes commits cheaper in WAL mode and can lose the last commits on power loss. |
accessUsage.flushIntervalMs | number | one minute | How often buffered usage is written (at least one second). |
accessUsage.maxBuffered | number | 10000 | Buffered identity and action pairs that trigger an early write. |
observability.metrics.gauges | boolean | false | Reads the outbox and session collections on each scrape. Leave it off on very large deployments. |
authentication.rateLimits.limiter | RateLimiter | counters in the IAM database | Moves rate-limit counters to a shared cache such as Redis; implement consume(), and reset() if identities.unlock should clear its counters. |
Verify it works
- The
instrumentStorecallback reports fewer calls per request after you replace loops with batch calls, and the slow-call log stays quiet. better_iam_span_duration_secondsforkind="authorize"andkind="http"falls, andbetter_iam_spans_totalwithcode="STORAGE_BUSY"stays at zero.better-iam doctor --strictreports noschema-behind,sweep-backlog, oroutbox-stalledfindings.
Troubleshooting
Next steps
authorizeMany, listAccessible, and rendering UI from decisions.
Query plans compiled to SQL, Prisma, or MongoDB.
Database operationsMigrations, durability, indexes, and backups.
ObservabilitySpans, metrics, and what to alert on.
Scheduled jobsThe background work that keeps requests fast.
Production checklistEverything else to confirm before launch.
Better IAM is created by Sean Filimon
Last updated
Production checklist
A grouped checklist of everything to configure, schedule, and monitor before a Better IAM deployment serves real users, with a link to the page that explains each item.
Run in multiple regions
Deploy Better IAM in several regions, give each organization a home region and its own sign-in address, and send sign-in to the right region with WRONG_REGION redirects.