qqq Performance: Benchmarks and Best Practices

We share performance benchmarks from production deployments and best practices for optimizing your qqq applications at scale.

Darin Kelkhoff
Darin Kelkhoff - Co-Founder
qqq Performance: Benchmarks and Best Practices

When evaluating a framework, performance matters. We've benchmarked qqq against common enterprise workloads and want to share the results along with optimization techniques we've learned from production deployments.

Performance is a feature, not an afterthought. We've optimized qqq's runtime to handle enterprise workloads, and we're sharing the benchmarks and techniques that keep our largest deployments running smoothly.

Benchmark Methodology

Our benchmarks use realistic enterprise scenarios rather than synthetic tests. We measure:

  • API Response Time: End-to-end latency for CRUD operations
  • Throughput: Requests per second under sustained load
  • Query Performance: Complex queries with joins, filters, and aggregations
  • Memory Efficiency: Heap usage under various workloads
  • Startup Time: Cold start to serving requests

All tests run on standardized infrastructure: 4 vCPU, 8GB RAM, PostgreSQL 15, with JDK 17.

API Response Time Benchmarks

| Operation | p50 | p95 | p99 | |-----------|-----|-----|-----| | Single record GET | 3ms | 8ms | 15ms | | List (25 records) | 12ms | 25ms | 45ms | | List with filters | 15ms | 35ms | 60ms | | Single INSERT | 8ms | 18ms | 30ms | | Bulk INSERT (100) | 45ms | 85ms | 120ms | | UPDATE | 6ms | 15ms | 25ms | | DELETE | 5ms | 12ms | 20ms |

These numbers include JSON serialization, authentication validation, and audit logging. The metadata layer adds approximately 1-2ms overhead compared to hand-written endpoints.

Throughput Under Load

We tested sustained load using a realistic mix of operations (60% reads, 30% writes, 10% complex queries):

| Concurrent Users | Requests/sec | Avg Response | Error Rate | |------------------|--------------|--------------|------------| | 50 | 850 | 12ms | 0% | | 100 | 1,450 | 18ms | 0% | | 250 | 2,800 | 35ms | 0% | | 500 | 4,200 | 65ms | 0.1% | | 1000 | 5,100 | 120ms | 0.8% |

Throughput scales linearly until CPU saturation around 500 concurrent users on this hardware profile.

Query Performance

Complex queries are where frameworks often struggle. Here's how qqq handles common patterns:

java
// Query: Orders with line items, customer, and product details
// Filters: Last 30 days, status = SHIPPED, total > $100
// Sort: By order date descending
// Pagination: 25 records
 
QueryInput query = new QueryInput()
   .withTableName("order")
   .withFilter(new QQueryFilter()
      .withCriteria("orderDate", GREATER_THAN, thirtyDaysAgo)
      .withCriteria("status", EQUALS, "SHIPPED")
      .withCriteria("total", GREATER_THAN, 100))
   .withOrderBy(new QFilterOrderBy("orderDate", false))
   .withIncludeAssociations(List.of("lineItems", "customer"))
   .withLimit(25);
 
// Execution time: 18ms average (with proper indexes)

Query Optimization Results

| Query Type | Unoptimized | Optimized | Improvement | |------------|-------------|-----------|-------------| | Simple filter | 8ms | 3ms | 2.7x | | Multi-table join | 85ms | 22ms | 3.9x | | Aggregation query | 120ms | 35ms | 3.4x | | Full-text search | 250ms | 45ms | 5.5x |

Optimization techniques include proper indexing, query hints, and leveraging qqq's built-in query planning.

Memory Efficiency

qqq's metadata-driven architecture has a fixed memory overhead regardless of table count:

| Tables Defined | Heap at Startup | Heap Under Load | |----------------|-----------------|-----------------| | 10 | 180MB | 350MB | | 50 | 195MB | 380MB | | 100 | 220MB | 420MB | | 500 | 310MB | 550MB |

Adding more tables increases metadata storage but doesn't multiply memory usage like traditional ORMs that generate entity classes.

Startup Performance

Cold start times matter for serverless deployments and rapid scaling:

| Configuration | Startup Time | |---------------|--------------| | Minimal (5 tables) | 1.2s | | Standard (25 tables) | 2.1s | | Large (100 tables) | 4.5s | | Enterprise (500 tables) | 12s |

Most of startup time is JVM initialization and dependency injection. The metadata loading phase is under 500ms for typical applications.

Optimization Best Practices

1. Index Strategy

qqq generates efficient SQL, but it can't create indexes for you. Add indexes for:

sql
-- Filter fields
CREATE INDEX idx_order_status ON orders(status);
CREATE INDEX idx_order_date ON orders(order_date);
 
-- Foreign keys (if not auto-created)
CREATE INDEX idx_order_customer ON orders(customer_id);
 
-- Composite indexes for common query patterns
CREATE INDEX idx_order_status_date ON orders(status, order_date DESC);

2. Query Filter Optimization

Order your filter criteria by selectivity (most selective first):

java
// Good: Status (few values) filters before date range
new QQueryFilter()
   .withCriteria("status", EQUALS, "PENDING")  // ~5% of records
   .withCriteria("orderDate", GREATER_THAN, lastWeek)  // ~15% of records
 
// Less optimal: Date first processes more records
new QQueryFilter()
   .withCriteria("orderDate", GREATER_THAN, lastWeek)
   .withCriteria("status", EQUALS, "PENDING")

3. Association Loading

Use includeAssociations judiciously:

java
// Only load what you need
QueryInput query = new QueryInput()
   .withTableName("order")
   .withIncludeAssociations(List.of("customer"))  // Just customer, not all associations
 
// Avoid N+1 with explicit association loading
// qqq batches association queries automatically

4. Pagination

Always paginate large result sets:

java
QueryInput query = new QueryInput()
   .withTableName("order")
   .withLimit(25)          // Page size
   .withSkip(25 * page);   // Offset for pagination
 
// For large datasets, use cursor-based pagination
QueryInput query = new QueryInput()
   .withTableName("order")
   .withFilter(new QQueryFilter()
      .withCriteria("id", GREATER_THAN, lastSeenId))
   .withLimit(25);

5. Caching Strategy

qqq supports multiple caching layers:

java
// Table-level caching for reference data
new QTableMetaData()
   .withName("country")
   .withCachePolicy(new QCachePolicy()
      .withTTL(Duration.ofHours(24))
      .withMaxSize(500));
 
// Use the Cache qBit for complex caching needs
qInstance.addQBit(new CacheQBit()
   .withProvider(new RedisProvider(redisUrl))
   .withDefaultTTL(Duration.ofMinutes(15)));

Scaling Strategies

Horizontal Scaling

qqq applications are stateless by default, making horizontal scaling straightforward:

yaml
# Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: qqq-app
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "2Gi"
            cpu: "2000m"

With a load balancer, you can scale to handle thousands of concurrent users.

Read Replicas

For read-heavy workloads, configure read replicas:

java
QBackendMetaData backend = new QBackendMetaData()
   .withName("primary")
   .withConnection(primaryConnection)
   .withReadReplicas(List.of(
      new DatabaseConnection().withJdbcUrl(replica1Url),
      new DatabaseConnection().withJdbcUrl(replica2Url)
   ));
 
// qqq routes read queries to replicas automatically

Connection Pooling

Tune your connection pool for your workload:

java
DatabaseConnection connection = new DatabaseConnection()
   .withJdbcUrl(jdbcUrl)
   .withPoolSize(20)           // Connections per instance
   .withMaxIdleTime(600)       // Seconds before idle connection cleanup
   .withConnectionTimeout(30); // Seconds to wait for connection

Rule of thumb: poolSize = (connections_per_request * concurrent_requests) + buffer

Production Monitoring

Monitor these metrics in production:

java
// qqq exposes metrics via Micrometer
// Configure your preferred backend (Prometheus, DataDog, etc.)
 
// Key metrics to watch:
// - qqq.api.requests (counter by endpoint, status)
// - qqq.api.latency (histogram)
// - qqq.db.queries (counter by table, operation)
// - qqq.db.latency (histogram)
// - qqq.cache.hits/misses (counter)

Set alerts for:

  • p99 latency > 500ms
  • Error rate > 1%
  • Connection pool exhaustion
  • Memory usage > 80% of limit

Comparison to Hand-Written Code

The question we often hear: "How does qqq compare to hand-optimized code?"

| Aspect | Hand-Written | qqq | Delta | |--------|--------------|-----|-------| | Simple CRUD | Baseline | +5-10% overhead | Negligible | | Complex queries | Varies | Consistent | Often faster* | | Development time | 100% | 20-30% | 3-5x faster | | Bug rate | Higher | Lower | Fewer edge cases |

*qqq's query builder applies consistent optimizations that developers often miss in hand-written SQL.

The metadata overhead (1-2ms per request) is offset by consistent optimization patterns and significantly faster development time.

Conclusion

qqq delivers enterprise-grade performance out of the box. The metadata-driven approach adds minimal overhead while providing consistency, auditability, and rapid development that hand-written code can't match.

For most applications, qqq performs excellently without tuning. For high-scale deployments, the optimization techniques above can push performance further.


Questions about performance? Join our Discord or check out the performance tuning guide.

Read next

Ready to build faster?