Skip to content

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 / Parquet

Every 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

MetricValue
Duration60s
Errors0
Avg latency36 ms
p5035 ms
p9544 ms
p9968 ms
Max144 ms

Steady-State: 200 RPS

MetricValue
Duration60s
Errors0
Avg latency45 ms
p5033 ms
p9563 ms
p99552 ms
Max916 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

MetricValue
Duration62s
Total requests15,805
Total events79,025
Errors0 (100% success)
Actual RPS achieved254.9 (avg)
Avg latency32 ms
p5028 ms
p9541 ms
p99120 ms
Max623 ms

Burst: 1,000 Concurrent Requests

MetricValue
Duration2.6s
Total requests1,000
Total events10,000
Errors0 (100% success)
Actual RPS387
Avg latency62 ms
p5060 ms
p9585 ms
p99133 ms
Max1,038 ms

Summary Table

MetricSteady 50Steady 200Ramp 10→500Burst 1k
Errors0000
Avg36 ms45 ms32 ms62 ms
p5035 ms33 ms28 ms60 ms
p9544 ms63 ms41 ms85 ms
p9968 ms552 ms120 ms133 ms
Max144 ms916 ms623 ms1,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

WorkloadEvents/hourSupported?
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.

ApproachRequests/secEvents/secEfficiency
1 event per request200200❌ Poor
10 events per request2002,000⚠️ OK
50–100 events per request20010,000–20,000✅ Optimal

The SDK handles batching automatically:

javascript
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.

javascript
// ❌ 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.

javascript
// ❌ 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:

TypeUse ForExample Values
stringURLs, IDs, names, categories"signup_btn", "/pricing"
jsonNested objects stored as JSON string{"meta": {"browser": "chrome"}}
int32Small numbers, counts, ports42, 8080
int64Large numbers, epoch timestamps1771413863920
timestampDate/time values (auto-formatted)1771413863920 or "2026-02-18T11:24:23Z"
float32Ratings, approximate values4.5
float64Prices, scores, coordinates99.99, 52.5200
booleanFlags, togglestrue, false
json
{
  "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

ScenarioModeWhy
PrototypingflexAccept everything, iterate fast
Production SaaSevolveDetect upstream changes automatically
Financial / compliancestrictReject 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:

javascript
window.addEventListener('beforeunload', () => {
  enrich.beacon() // Uses fetch({ keepalive: true })
})

7. Implement Server-Side Retry Logic

For custom integrations (without the SDK), add exponential backoff:

javascript
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:

bash
# 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 20

Configure with environment variables:

VariableDefaultDescription
ENRICH_TOKENYour API key (sk_test_ or sk_live_)
ENRICH_URLhttps://enrich.sh/ingestIngest endpoint
ENRICH_STREAMloadtestTarget stream

Latency Breakdown

Where does the latency come from?

PhaseTypicalNotes
Client → Edge5–15 msDepends on geographic distance to nearest point of presence
Edge → Node1–5 msInternal network routing
Node processing1–3 msSchema validation, buffering, type coercion
Response5–15 msReturn 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

StageThroughputHow
Single nodeUp to 500 rps / 2,500 events/secDefault for all streams
Dedicated nodeUp to 500 rps per nodeIsolate high-traffic streams from noisy neighbors
Multi-node shardingThousands of rpsMultiple nodes per stream, automatic load distribution
Multi-regionGlobal scale300+ 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.

Serverless data ingestion for developers.