Skip to main content

Cache Monitoring

Balda provides a comprehensive cache monitoring system to track schema compilation and serialization performance. This is essential for understanding memory usage and optimizing long-running applications.

Overview

The framework uses three parallel caches for optimal performance:

  1. Validator Cache (openapiSchemaMap) - Compiled AJV validators
  2. Serializer Cache (fastJsonStringifyMap) - fast-json-stringify functions
  3. JSON Schema Cache (jsonSchemaCache) - Converted JSON schemas for Swagger/OpenAPI

All caches use WeakMap-based keys for automatic garbage collection when schemas are no longer referenced.

Cache Types

1. Validator Cache

Purpose: Stores compiled AJV validators for request/response validation

Key Type: Symbol (from WeakMap) or string (for primitives)

Lifecycle: Application lifetime

Used by:

  • @validate decorator
  • Request.validate()
  • @serialize decorator (when throwErrorOnValidationFail: true)

Memory: ~1-5KB per validator

2. Serializer Cache

Purpose: Stores fast-json-stringify functions for high-performance JSON serialization

Key Type: Symbol (shared with validators)

Lifecycle: Application lifetime

Used by:

  • @serialize decorator
  • Response.json() with schemas

Memory: ~2-10KB per serializer

3. JSON Schema Cache

Purpose: Stores converted JSON schemas for Swagger/OpenAPI documentation

Key Type: Symbol (shared with validators and serializers)

Lifecycle: Application lifetime

Used by:

  • Swagger plugin
  • OpenAPI spec generation

Memory: ~0.5-2KB per schema

API Reference

getCacheMetrics()

Returns comprehensive metrics about all schema caches.

Returns: CacheMetrics

interface CacheMetrics {
validators: {
size: number;
description: string;
};
serializers: {
size: number;
schemaRefsCreated: number;
entries: Array<{
key: string;
compiledAt: number;
schemaType: string;
}>;
};
jsonSchemas: {
size: number;
description: string;
};
totalSchemaReferences: number;
memoryEstimate: {
validators: string;
serializers: string;
jsonSchemas: string;
total: string;
};
}

Example:

import { getCacheMetrics } from 'balda';

const metrics = getCacheMetrics();

console.log(`Validators cached: ${metrics.validators.size}`);
console.log(`Serializers cached: ${metrics.serializers.size}`);
console.log(`JSON schemas cached: ${metrics.jsonSchemas.size}`);
console.log(`Total memory estimate: ${metrics.memoryEstimate.total}`);
console.log(`Schema references created: ${metrics.totalSchemaReferences}`);

// Inspect individual serializer entries
metrics.serializers.entries.forEach((entry) => {
console.log(`${entry.key}: ${entry.schemaType} (compiled ${Date.now() - entry.compiledAt}ms ago)`);
});

logCacheMetrics()

Logs cache metrics using the structured logger (Pino).

Returns: void

Example:

import { logCacheMetrics } from 'balda';

// Log at server startup
server.listen(3000, () => {
logCacheMetrics();
});

// Log periodically for monitoring
setInterval(() => {
logCacheMetrics();
}, 60000); // Every minute

Output format:

{
"level": 30,
"time": 1706543210000,
"msg": "Schema cache metrics",
"validators": 42,
"serializers": 38,
"jsonSchemas": 40,
"totalSchemaRefs": 45,
"memoryEstimate": "~1.2MB"
}

clearAllCaches()

Clears all schema caches. This forces all schemas to be recompiled on next use.

Returns: void

Warning: This will impact performance as schemas need to be recompiled. Only use in specific scenarios:

  • Testing environments
  • Memory pressure situations
  • Hot reload during development

Example:

import { clearAllCaches } from 'balda';

// Clear during hot reload
if (import.meta.hot) {
import.meta.hot.dispose(() => {
clearAllCaches();
});
}

// Clear in tests
afterEach(() => {
clearAllCaches();
});

Cache Invalidation Strategy

Current Strategy

No automatic invalidation - Caches persist for the application lifetime.

Why?

  • Schema objects rarely change at runtime
  • Pre-compilation at startup provides optimal performance
  • WeakMap keys ensure garbage collection when schemas are unreferenced

When Caches Are Populated

Caches are automatically populated during:

  1. Route registration - Eager compilation via router.addOrUpdate()
  2. Decorator application - When @serialize or @validate decorators are applied
  3. First request - Lazy compilation for dynamic routes
  4. Swagger generation - When OpenAPI spec is generated

Cache Warming

Best practice: Pre-compile all schemas at startup for optimal performance.

Schemas defined in route decorators and inline routes are automatically pre-compiled during route registration, ensuring the first request is fast.

import { Server, controller, get, serialize } from 'balda';
import { z } from 'zod';

const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});

@controller('/users')
class UserController {
// Schema is pre-compiled during controller registration
@get('/:id')
@serialize(UserSchema)
getUser() {
return { id: 1, name: 'John', email: 'john@example.com' };
}
}

// All schemas compiled during bootstrap
const server = new Server({ controllers: [UserController] });
await server.bootstrap();
await server.listen(3000);

Memory Considerations

Automatic Garbage Collection

  • Schema objects use WeakMap-based keys
  • When a schema object is no longer referenced, its cache entry is automatically garbage collected
  • No manual cleanup needed for unreferenced schemas

Long-Running Processes

For applications with dynamic schema generation:

  1. Monitor cache size regularly using getCacheMetrics()
  2. Set up alerts when memory exceeds thresholds
  3. Consider manual clearing if memory pressure is detected
import { getCacheMetrics, clearAllCaches, logger } from 'balda';

// Monitor cache size every 5 minutes
setInterval(() => {
const metrics = getCacheMetrics();
const totalCacheSize =
metrics.validators.size +
metrics.serializers.size +
metrics.jsonSchemas.size;

if (totalCacheSize > 10000) {
logger.warn({ totalCacheSize }, 'Cache size exceeds threshold');

// Consider clearing if memory pressure is critical
// clearAllCaches();
}
}, 300000);

Memory Estimates

Cache metrics include rough memory estimates based on average sizes:

  • Each validator: ~1-5KB
  • Each serializer: ~2-10KB
  • Each JSON schema: ~0.5-2KB

Actual sizes vary based on schema complexity.

Use Cases

Health Check Endpoints

Expose cache metrics in health check endpoints:

import { Server, controller, get, getCacheMetrics } from 'balda';

@controller('/health')
class HealthController {
@get('/cache')
getCacheHealth() {
const metrics = getCacheMetrics();

return {
status: 'healthy',
cache: {
validators: metrics.validators.size,
serializers: metrics.serializers.size,
jsonSchemas: metrics.jsonSchemas.size,
memory: metrics.memoryEstimate.total,
},
timestamp: Date.now(),
};
}
}

Performance Monitoring Dashboards

Integrate with monitoring tools like Prometheus, Grafana, or Datadog:

import { getCacheMetrics } from 'balda';
import { register, Gauge } from 'prom-client';

// Create Prometheus metrics
const cacheValidatorsGauge = new Gauge({
name: 'balda_cache_validators_total',
help: 'Total number of cached validators',
});

const cacheSerializersGauge = new Gauge({
name: 'balda_cache_serializers_total',
help: 'Total number of cached serializers',
});

const cacheMemoryGauge = new Gauge({
name: 'balda_cache_memory_kb',
help: 'Estimated cache memory usage in KB',
});

// Update metrics periodically
setInterval(() => {
const metrics = getCacheMetrics();

cacheValidatorsGauge.set(metrics.validators.size);
cacheSerializersGauge.set(metrics.serializers.size);

// Parse memory estimate (e.g., "~1.2MB" -> 1200)
const memoryMatch = metrics.memoryEstimate.total.match(/~([\d.]+)(KB|MB)/);
if (memoryMatch) {
const value = parseFloat(memoryMatch[1]);
const unit = memoryMatch[2];
const kb = unit === 'MB' ? value * 1024 : value;
cacheMemoryGauge.set(kb);
}
}, 15000);

Development Debugging

Log cache metrics during development to understand schema compilation:

import { Server, logCacheMetrics } from 'balda';

const server = new Server({
controllers: ['./controllers/**/*.ts'],
});

await server.bootstrap();

// Log cache metrics after all routes are registered
if (process.env.NODE_ENV === 'development') {
logCacheMetrics();
}

await server.listen(3000);

Testing Environments

Clear caches between tests to ensure isolation:

import { describe, it, beforeEach, afterEach } from 'vitest';
import { clearAllCaches, getCacheMetrics } from 'balda';

describe('Cache tests', () => {
beforeEach(() => {
clearAllCaches();
});

it('should start with empty caches', () => {
const metrics = getCacheMetrics();
expect(metrics.validators.size).toBe(0);
expect(metrics.serializers.size).toBe(0);
});
});

Performance Impact

Cache Hit vs Miss

Cache Hit (schema already compiled):

  • Validator lookup: ~0.01ms
  • Serializer lookup: ~0.01ms
  • JSON Schema lookup: ~0.01ms

Cache Miss (first compilation):

  • Validator compilation: ~5-50ms (depending on schema complexity)
  • Serializer compilation: ~10-100ms (depending on schema complexity)
  • JSON Schema conversion: ~1-10ms (Zod to JSON Schema)

Recommendation: Pre-compile all schemas at startup to avoid cache misses during requests.

Memory vs Speed Trade-off

Caching provides significant performance benefits with minimal memory overhead:

  • Speed gain: 100-1000x faster (0.01ms vs 10-100ms)
  • Memory cost: ~3-15KB per schema (all three caches combined)

For most applications, this is an excellent trade-off.

Best Practices

  1. Monitor in production - Set up periodic cache metrics logging
  2. Alert on anomalies - Create alerts for unusual cache growth
  3. Pre-compile schemas - Use route decorators for automatic pre-compilation
  4. Test cache behavior - Verify schemas are cached as expected
  5. Profile memory usage - Monitor memoryEstimate in long-running processes
  6. Clear intentionally - Only clear caches when necessary (testing, hot reload)
  7. Use structured logging - Always use logCacheMetrics() over manual console.log