Performance & Benchmarks
Enrich.sh is engineered for speed. Every customer gets an isolated compute node deployed at the edge — your data never shares resources with other tenants. The result: sub-100ms latency with zero infrastructure to manage.
This page shares real benchmark results from our load testing suite so you know exactly what to expect.
Architecture Overview
Client → Edge Network (300+ PoPs) → Isolated Node (buffer + validate) → Storage / ParquetEvery ingest request is routed to the nearest edge node, then forwarded to your dedicated, isolated compute node that buffers, validates, and flushes events to Parquet files. This gives you:
- Edge-quality latency — requests are processed within the edge network
- Automatic geographic routing — clients hit the closest point of presence
- Tenant isolation — your node is not shared with other customers
- Zero cold-start for active streams — nodes stay warm under load
- Horizontal scaling via sharding — each stream gets its own node; multi-node sharding available on higher tiers
Benchmark Results
All tests were run against the production endpoint (https://enrich.sh/ingest) using our open-source load testing tool.
Steady-State: 50 RPS
| Metric | Value |
|---|---|
| Duration | 60s |
| Errors | 0 |
| Avg latency | 36 ms |
| p50 | 35 ms |
| p95 | 44 ms |
| p99 | 68 ms |
| Max | 144 ms |
Steady-State: 200 RPS
| Metric | Value |
|---|---|
| Duration | 60s |
| Errors | 0 |
| Avg latency | 45 ms |
| p50 | 33 ms |
| p95 | 63 ms |
| p99 | 552 ms |
| Max | 916 ms |
INFO
The occasional p99 spike at 200 RPS occurs during Parquet flush operations — the node is writing a file to storage while still accepting new events. This is normal and does not affect data integrity.
Ramp: 10 → 500 RPS over 60 seconds
| Metric | Value |
|---|---|
| Duration | 62s |
| Total requests | 15,805 |
| Total events | 79,025 |
| Errors | 0 (100% success) |
| Actual RPS achieved | 254.9 (avg) |
| Avg latency | 32 ms |
| p50 | 28 ms |
| p95 | 41 ms |
| p99 | 120 ms |
| Max | 623 ms |
Burst: 1,000 Concurrent Requests
| Metric | Value |
|---|---|
| Duration | 2.6s |
| Total requests | 1,000 |
| Total events | 10,000 |
| Errors | 0 (100% success) |
| Actual RPS | 387 |
| Avg latency | 62 ms |
| p50 | 60 ms |
| p95 | 85 ms |
| p99 | 133 ms |
| Max | 1,038 ms |
Summary Table
| Metric | Steady 50 | Steady 200 | Ramp 10→500 | Burst 1k |
|---|---|---|---|---|
| Errors | 0 | 0 | 0 | 0 |
| Avg | 36 ms | 45 ms | 32 ms | 62 ms |
| p50 | 35 ms | 33 ms | 28 ms | 60 ms |
| p95 | 44 ms | 63 ms | 41 ms | 85 ms |
| p99 | 68 ms | 552 ms | 120 ms | 133 ms |
| Max | 144 ms | 916 ms | 623 ms | 1,038 ms |
Key Takeaway
The ramp test outperforms steady 200 RPS because the node warms up gradually — JIT optimization kicks in, caches fill, and flush operations process smaller, faster batches.
What This Means for You
| Workload | Events/hour | Supported? |
|---|---|---|
| Small SaaS (analytics) | ~50,000 | ✅ Easily |
| Mid-size app (clickstream) | ~500,000 | ✅ Comfortable |
| High-throughput API (transactions) | ~2,000,000 | ✅ Within single-node limits |
| Enterprise (multi-stream) | 10,000,000+ | ✅ With dedicated sharding |
A single isolated node comfortably handles 500 requests/sec (2,500+ events/sec with batching) with:
- ✅ Zero errors
- ✅ 28 ms median latency (edge-quality)
- ✅ 120 ms p99 (very acceptable)
- ✅ 623 ms worst case (during flush)
For workloads exceeding this, dedicated sharding distributes events across multiple nodes per stream — scaling horizontally with zero code changes.
Performance Best Practices
1. Batch Events ⚡
The single biggest performance lever. Each HTTP request has fixed overhead — amortize it across many events.
| Approach | Requests/sec | Events/sec | Efficiency |
|---|---|---|---|
| 1 event per request | 200 | 200 | ❌ Poor |
| 10 events per request | 200 | 2,000 | ⚠️ OK |
| 50–100 events per request | 200 | 10,000–20,000 | ✅ Optimal |
The SDK handles batching automatically:
import { Enrich } from 'enrich.sh'
const enrich = new Enrich('sk_live_your_key')
// Just call track() — the SDK batches and flushes for you
enrich.track('events', { event: 'page_view', url: '/home' })
enrich.track('events', { event: 'click', element: 'signup_btn' })2. Keep Payloads Lean
Every byte is stored, processed, and written to Parquet. Send only what you need.
// ❌ Bad: Sending bloated payloads
{ event: 'click', page_html: '<!DOCTYPE html>...', cookies: '...' }
// ✅ Good: Lean and queryable
{ event: 'click', element_id: 'signup_btn', page: '/pricing' }3. Use Consistent Types
Mixed types in a column force string fallback in Parquet, bloating file sizes and slowing queries.
// ❌ Inconsistent types — some sources send strings, others send numbers
{ user_id: 12345, amount: 99.99 } // from your API
{ user_id: "12345", amount: "99.99" } // from a CSV import
// ✅ Normalize before sending — everything is numeric
{ user_id: 12345, amount: 99.99 }
{ user_id: 12345, amount: 49.00 }4. Define Field Types in Your Schema
Even in flex mode, declaring types produces smaller, faster Parquet files. Enrich.sh supports 8 data types:
| Type | Use For | Example Values |
|---|---|---|
string | URLs, IDs, names, categories | "signup_btn", "/pricing" |
json | Nested objects stored as JSON string | {"meta": {"browser": "chrome"}} |
int32 | Small numbers, counts, ports | 42, 8080 |
int64 | Large numbers, epoch timestamps | 1771413863920 |
timestamp | Date/time values (auto-formatted) | 1771413863920 or "2026-02-18T11:24:23Z" |
float32 | Ratings, approximate values | 4.5 |
float64 | Prices, scores, coordinates | 99.99, 52.5200 |
boolean | Flags, toggles | true, false |
{
"stream_id": "transactions",
"schema_mode": "evolve",
"fields": {
"ts": { "name": "timestamp", "type": "timestamp" },
"user_id": { "type": "int64" },
"amount": { "type": "float64" },
"currency": { "type": "string" },
"metadata": { "type": "json" },
"is_refund": { "type": "boolean" }
}
}Without explicit types, columns default to string. With types, analytical engines (DuckDB, ClickHouse, Spark) read the data significantly faster.
Use strict mode + DLQ to catch type issues early
In strict mode, events that don't match your schema are rejected to the Dead Letter Queue instead of silently corrupting your data. Review DLQ events to find upstream sources sending the wrong types — then fix them before they become bad Parquet columns. See Schema Modes for details.
5. Choose the Right Schema Mode
| Scenario | Mode | Why |
|---|---|---|
| Prototyping | flex | Accept everything, iterate fast |
| Production SaaS | evolve | Detect upstream changes automatically |
| Financial / compliance | strict | Reject non-conforming data |
6. Use the SDK's beacon() for Page Unload
Don't lose the last batch of events when users close the tab:
window.addEventListener('beforeunload', () => {
enrich.beacon() // Uses fetch({ keepalive: true })
})7. Implement Server-Side Retry Logic
For custom integrations (without the SDK), add exponential backoff:
async function sendWithRetry(events, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const res = await fetch('https://enrich.sh/ingest', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stream_id: 'events', data: events }),
})
if (res.ok) return await res.json()
if (res.status >= 400 && res.status < 500) throw new Error(`Client error: ${res.status}`)
if (attempt < maxRetries) await new Promise(r => setTimeout(r, 2 ** attempt * 1000))
} catch (err) {
if (attempt === maxRetries) throw err
}
}
}TIP
The SDK handles retries, batching, and beacon automatically. Use it unless you have a specific reason not to.
Run Your Own Load Tests
We include a load testing script you can run against your own streams:
# Steady-state: 50 requests/sec for 60 seconds
node scripts/loadtest.js --rps 50
# Ramp: 10 → 500 rps over 60 seconds
node scripts/loadtest.js --ramp 10,500,60
# Burst: 1000 concurrent requests
node scripts/loadtest.js --burst 1000
# Custom batch size (events per request)
node scripts/loadtest.js --rps 100 --batch 20Configure with environment variables:
| Variable | Default | Description |
|---|---|---|
ENRICH_TOKEN | — | Your API key (sk_test_ or sk_live_) |
ENRICH_URL | https://enrich.sh/ingest | Ingest endpoint |
ENRICH_STREAM | loadtest | Target stream |
Latency Breakdown
Where does the latency come from?
| Phase | Typical | Notes |
|---|---|---|
| Client → Edge | 5–15 ms | Depends on geographic distance to nearest point of presence |
| Edge → Node | 1–5 ms | Internal network routing |
| Node processing | 1–3 ms | Schema validation, buffering, type coercion |
| Response | 5–15 ms | Return path |
| Total | ~25–40 ms |
During a flush (Parquet write + storage upload), the node continues accepting events but may add 50–500 ms to concurrent requests. This is a normal, infrequent operation (~every 60s or 10,000 events).
Scaling Path
| Stage | Throughput | How |
|---|---|---|
| Single node | Up to 500 rps / 2,500 events/sec | Default for all streams |
| Dedicated node | Up to 500 rps per node | Isolate high-traffic streams from noisy neighbors |
| Multi-node sharding | Thousands of rps | Multiple nodes per stream, automatic load distribution |
| Multi-region | Global scale | 300+ edge locations, each with local compute |
You never need to provision servers, manage queues, or tune thread pools. Scaling is a configuration change in your dashboard.
