Technical reference for performance optimization techniques, caching strategies, and performance monitoring in Allo-Scrapper.
Last updated: March 7, 2026
Related Documentation:
- Configuration Guide - Environment variables
- System Design - Architecture overview
- Monitoring Guide - Observability stack
- Overview
- JSON Parse Caching
- Database Query Optimization
- Performance Monitoring
- Tuning Guidelines
- Troubleshooting Performance Issues
Allo-Scrapper implements several performance optimizations to handle high-volume database queries and API requests efficiently. The primary optimization is JSON parse caching, which eliminates redundant JSON parsing for frequently repeated data.
- API Response Time: <100ms for typical queries (p95)
- Database Query Volume: 100-500 queries/second under load
- JSON Parse Cache Hit Rate: 95-99% in production
- Memory Footprint: ~50-100 MB baseline + cache overhead (1-20 MB)
Database queries like getWeeklyMovies() return hundreds of rows where JSON-encoded fields are frequently duplicated:
- Genres:
'["Action","Thriller"]'repeated across many action movies - Actors:
'[]'(empty array) appears in multiple columns (testExperiences,testActors) - Experiences:
'["IMAX","3D"]'repeated for all IMAX movies
Without caching, every row triggers a separate JSON.parse() call, even for identical strings.
┌─────────────────────────────────────────────────────────────┐
│ Database Query │
│ getWeeklyMovies() → 500 rows × 4 JSON fields = 2000 parses │
└────────────────────────┬────────────────────────────────────┘
│
↓
┌────────────────────────┐
│ parseJSONMemoized() │
│ Check cache first │
└───────┬────────────────┘
│
┌──────────┴──────────┐
│ │
↓ Cache Hit (99%) ↓ Cache Miss (1%)
Return cached JSON.parse()
deep clone Store in cache
Return deep clone
File: server/src/utils/json-parse-cache.ts
Key Features:
- LRU Eviction: Gradual turnover, no cache cliffs
- Deep Cloning: Uses
structuredClone()to prevent shared mutable state - Configurable Size: Environment variable
JSON_PARSE_CACHE_SIZE - Metrics Tracking: Cache hits, misses, hit rate
import { parseJSONMemoized } from './utils/json-parse-cache.js';
// In database queries
const movies = rows.map(row => ({
id: row.id,
title: row.title,
genres: parseJSONMemoized(row.genres), // Cached
actors: parseJSONMemoized(row.actors), // Cached
experiences: parseJSONMemoized(row.experiences) // Cached
}));Test Configuration:
- 150,000 JSON.parse operations
- 16 unique JSON strings (realistic theater data)
- Node.js 20.x on Apple M1
Results:
Metric Value
─────────────────────────────────────
Total parses 150,000
Unique strings 16
Cache hits 149,984 (99.9%)
Cache misses 16 (0.01%)
Throughput ~2.4M parses/sec
Time saved ~750ms
Memory overhead ~1-2 MB
Interpretation:
- 99.9% hit rate: Nearly all parses are cache hits
- 750ms saved: Cumulative time saved across 150k operations
- Minimal overhead: <2 MB memory for 10,000 cache entries
Set cache size via environment variable:
# .env
JSON_PARSE_CACHE_SIZE=10000 # DefaultSizing Guidelines:
| Deployment Size | Recommended Size | Memory Impact |
|---|---|---|
| Small (1-10 theaters, <100 movies) | 5,000 | ~0.5-1 MB |
| Medium (10-50 theaters, 100-500 movies) | 10,000 (default) | ~1-2 MB |
| Large (50+ theaters, 500+ movies) | 50,000 | ~5-10 MB |
| Very Large (100+ theaters, 1000+ movies) | 100,000 | ~10-20 MB |
Memory Calculation:
Typical JSON string: ~100 bytes (e.g., '["IMAX","3D"]')
Cache overhead: ~100 bytes per entry (LRU metadata)
Total per entry: ~200 bytes
10,000 entries × 200 bytes = 2 MB
50,000 entries × 200 bytes = 10 MB
import { getJSONParseCacheStats } from './utils/json-parse-cache.js';
// Get current statistics
const stats = getJSONParseCacheStats();
console.log({
size: stats.size, // Current entries in cache
maxSize: stats.maxSize, // Max cache size
hits: stats.hits, // Total cache hits
misses: stats.misses, // Total cache misses
hitRate: stats.hitRate // Hit rate (0.0-1.0)
});
// Example output:
// {
// size: 8432,
// maxSize: 10000,
// hits: 1498420,
// misses: 8432,
// hitRate: 0.9944
// }Recommended Actions:
| Hit Rate | Action |
|---|---|
| >95% | Cache is working optimally |
| 85-95% | Monitor; consider increasing cache size |
| 70-85% | Increase JSON_PARSE_CACHE_SIZE by 2x |
| <70% | Data has high cardinality; caching may not help |
Allo-Scrapper uses pg connection pooling to manage concurrent database connections efficiently.
Configuration (server/src/db/index.ts):
const pool = new Pool({
max: 20, // Max connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});Efficient Patterns:
-- Good: Fetch only needed columns
SELECT id, title, genres, actors FROM movies WHERE active = true;
-- Good: Use indexes
SELECT * FROM showtimes WHERE theater_id = $1 AND date = $2;
-- Requires index on (theater_id, date)Inefficient Patterns:
-- Bad: SELECT * with large JSON columns
SELECT * FROM movies; -- Fetches unnecessary data
-- Bad: Missing WHERE clause on large tables
SELECT * FROM showtimes; -- Full table scanKey Indexes (see migrations/001_initial_schema.sql):
-- Theaters
CREATE INDEX idx_theaters_active ON theaters(active);
-- Movies
CREATE INDEX idx_movies_allocine_id ON movies(allocine_id);
CREATE INDEX idx_movies_active ON movies(active);
-- Showtimes
CREATE INDEX idx_showtimes_theater_id ON showtimes(theater_id);
CREATE INDEX idx_showtimes_movie_id ON showtimes(movie_id);
CREATE INDEX idx_showtimes_date ON showtimes(date);
CREATE INDEX idx_showtimes_date_theater ON showtimes(date, theater_id);Prometheus Metrics (scraper microservice only):
scraper_theater_scrape_duration_seconds- Scrape duration per theaterscraper_movies_scraped_total- Total movies scrapedscraper_errors_total- Total scrape errors
Access: http://localhost:9091/metrics (scraper container)
import { logger } from './utils/logger.js';
// Log slow queries
const start = Date.now();
const results = await pool.query(query, params);
const duration = Date.now() - start;
if (duration > 100) {
logger.warn('Slow query detected', { query, duration, rowCount: results.rowCount });
}OpenTelemetry (optional, requires monitoring profile):
# Enable tracing
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://ics-tempo:4317
# Start with monitoring
docker compose --env-file .env --env-file .env.monitoring -f docker-compose.yaml -f docker-compose.monitoring.yml up -d
# View traces in Grafana
open http://localhost:3001
# Explore → Tempo → Search tracesSymptom: Low cache hit rate (<90%) in logs
Solution:
# .env
JSON_PARSE_CACHE_SIZE=50000 # Increase from default 10000Verify:
const stats = getJSONParseCacheStats();
console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
// Expected: >95%Symptom: Slow API responses (>200ms p95)
Solution:
- Add missing indexes
- Reduce
SELECT *queries - Use query batching
Example:
// Before: N+1 query problem
for (const theater of theaters) {
const showtimes = await getShowtimesByTheater(theater.id);
}
// After: Single batch query
const theaterIds = theaters.map(c => c.id);
const showtimes = await getShowtimesByIds(theaterIds);Symptom: Connection timeout errors
Solution:
// server/src/db/index.ts
const pool = new Pool({
max: 50, // Increase from default 20
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});Trade-offs:
- More connections = higher memory usage
- Too few connections = request queuing
Formula: max connections ≈ (core count × 2) + effective_spindle_count
Symptom: Large response payloads (>100 KB)
Solution: Already enabled in server/src/index.ts:
import compression from 'compression';
app.use(compression());Verification:
curl -H "Accept-Encoding: gzip" http://localhost:3000/api/movies -I
# Look for: Content-Encoding: gzipDiagnosis:
# Check query logs
docker compose logs ics-web | grep "duration"
# Monitor database connections
docker compose exec ics-db psql -U postgres -d ics -c "SELECT count(*) FROM pg_stat_activity;"Common Causes:
- Missing database indexes
- Large result sets (no pagination)
- N+1 query problem
- Slow external API calls (scraping)
Solutions:
- Add indexes for frequent WHERE/JOIN conditions
- Implement pagination (
LIMIT/OFFSET) - Batch queries to avoid N+1 pattern
- Cache external API responses
Diagnosis:
# Check container memory
docker stats --no-stream ics-web
# Check Node.js heap
docker compose exec ics-web node -e "console.log(process.memoryUsage())"Common Causes:
- Large JSON parse cache
- Connection pool leaks
- Large in-memory result sets
Solutions:
# Reduce cache size
JSON_PARSE_CACHE_SIZE=5000
# Reduce connection pool
# server/src/db/index.ts: max: 10
# Stream large queries instead of loading all at once
const cursor = pool.query(new Cursor(query));Diagnosis:
import { getJSONParseCacheStats } from './utils/json-parse-cache.js';
const stats = getJSONParseCacheStats();
console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
console.log(`Cache utilization: ${stats.size}/${stats.maxSize}`);Common Causes:
- Cache size too small
- High data cardinality (many unique values)
- Cache recently cleared/restarted
Solutions:
# Double cache size
JSON_PARSE_CACHE_SIZE=20000
# Wait for cache to warm up (15-30 minutes after restart)
# Check if caching is appropriate for your data
# If hit rate remains <70%, data may have too many unique valuesError: TimeoutError: ResourceRequest timed out
Solutions:
// Increase timeout
const pool = new Pool({
connectionTimeoutMillis: 5000, // Increase from 2000
max: 30 // Increase pool size
});# Check PostgreSQL max_connections
docker compose exec ics-db psql -U postgres -c "SHOW max_connections;"
# Increase if needed (postgresql.conf or docker-compose.yaml)// Log cache stats periodically
setInterval(() => {
const stats = getJSONParseCacheStats();
logger.info('JSON parse cache stats', {
hitRate: (stats.hitRate * 100).toFixed(1) + '%',
size: stats.size,
maxSize: stats.maxSize
});
}, 60000); // Every minute// Good: Paginated query
async function getMoviesPaginated(page = 1, limit = 50) {
const offset = (page - 1) * limit;
const { rows } = await pool.query(
'SELECT * FROM movies LIMIT $1 OFFSET $2',
[limit, offset]
);
return rows;
}import { performance } from 'perf_hooks';
app.get('/api/movies', async (req, res) => {
const start = performance.now();
const movies = await getMovies();
const duration = performance.now() - start;
logger.info('GET /api/movies', { duration: `${duration.toFixed(2)}ms`, count: movies.length });
res.json(movies);
});import { resetJSONParseCache } from './utils/json-parse-cache.js';
beforeEach(() => {
resetJSONParseCache(); // Ensure clean state
});- Configuration Guide -
JSON_PARSE_CACHE_SIZEand other env vars - System Design - Overall architecture
- Monitoring Guide - Prometheus, Grafana, Tempo
- Database Schema - Table structure and indexes