Performance & Benchmarks
How nano search performs on real IR/SOC investigations — measured under live production load, with the queries, latencies, and the concurrency knee.
Performance & Benchmarks
This page reports measured search performance for real incident-response and threat-hunting workloads — not synthetic micro-benchmarks. Every number below was captured against a live nano environment running production conditions, and every latency is the search engine's own server-side execution time unless noted.
All results were measured on 2026-05-31 against the public nano demo: a ClickHouse Cloud backend holding ~22M events per 24h, under ~200 EPS of continuous live ingestion with 26 detection rules executing concurrently. Search load was applied via k6 over a port-forward to a single search replica (of 4) using read-only JWT auth — i.e. one pod absorbing everything, with no load-balancer help.
TL;DR
Real, source-scoped investigations are fast at production scale:
- Triage queries (minutes to 1h) return in tens of milliseconds.
- Hunts (6–24h, multi-filter) land in the double-to-low-hundreds of milliseconds.
- Advanced, multi-stage IR (24h–7d) generally stays under ~2 seconds — even while the box ingests ~200 EPS and runs 26 detections.
The only slow queries are deliberate worst-case probes — bare, unscoped keyword scans over a full day — which are full-table scans no analyst actually runs (see the appendix). The lever that keeps everything else fast is source-scoping, not small time windows.
Real-world load test
The corpus is 94 validated queries across 14 SOC/IR domains and 46 distinct MITRE ATT&CK techniques, replayed at each query's natural analyst time window. The load test ran 3 concurrent analysts for 3 minutes against the single replica, on top of the live ingest + detection load.
1,117 queries executed · 0.18% error rate · every analyst-tier SLO met.
| Analyst tier | Window | p50 | p95 | max | p95 target | Met |
|---|---|---|---|---|---|---|
| Triage | 15m–1h | 85 ms | 785 ms | 2.6 s | 1 s | ✅ |
| Hunt | 6–24h | 167 ms | 1.01 s | 2.2 s | 2 s | ✅ |
| Advanced IR | 24h–7d | 204 ms | 1.97 s | 5.7 s | 5 s | ✅ |
| Overall | mixed | 147 ms | 1.10 s | 5.7 s | — | — |
Run one query at a time (no contention) and latencies are even lower — overall median 83 ms, p95 489 ms, max 1.5 s. The table above includes 3-way analyst contention plus live ingestion and detection load, so it reflects what an analyst actually feels on a busy box.
By SOC domain
Server-side latency, broken down by investigation domain (most scope to a single source_type; cross-source IR and impossible-travel intentionally span several):
| Domain (source) | p50 | p95 | max |
|---|---|---|---|
Web access (apache_access) | 57 ms | 313 ms | 688 ms |
Network / proxy egress (conduit_proxy) | 97 ms | 373 ms | 1.16 s |
Cloud IAM abuse (aws_cloudtrail) | 63 ms | 784 ms | 1.59 s |
Identity / auth (windows_event) | 126 ms | 793 ms | 1.69 s |
Cloud / AWS CloudTrail (aws_cloudtrail) | 124 ms | 799 ms | 2.11 s |
C2 / DNS beaconing (windows_sysmon) | 173 ms | 653 ms | 1.62 s |
| Cross-source IR (entity pivots) | 165 ms | 650 ms | 2.19 s |
| Impossible travel / geo-velocity | 187 ms | 716 ms | 2.10 s |
| Exfiltration & staging | 159 ms | 729 ms | 1.69 s |
| Lateral movement | 152 ms | 927 ms | 2.45 s |
| Defense evasion (Sysmon) | 253 ms | 1.08 s | 1.93 s |
| Persistence (Sysmon) | 287 ms | 1.30 s | 2.6 s |
| Discovery / recon | 252 ms | 1.50 s | 1.74 s |
Endpoint / EDR (windows_sysmon) | 356 ms | 4.38 s | 5.7 s |
Endpoint/EDR is the one heavy tail: two of its queries match on Sysmon fields that aren't promoted to indexed UDM columns (they live in the raw message), so they fall back to a full-text scan. Everything else stays sub-second-to-~1.5 s at p95.
Representative queries
These are real nPL queries from the corpus, with their measured single-shot server-side latency against the live demo. They're copy-pasteable starting points for your own hunts.
Triage — scoped, minutes to 1 hour
Encoded PowerShell · 1h · 40 ms
source_type=windows_sysmon action=process_create command_line=/EncodedCommand/i
| stats count by src_host, userBrute force by account · 1h · 33 ms
source_type=windows_event action=logon_failure
| stats count as failures, dc(src_ip) as src_ips by user
| where failures > 20Blocked egress · 1h · 24 ms
source_type=conduit_proxy action=deny OR action=blocked
| stats count by src_ip, dest_hostFailed AssumeRole · 1h · 43 ms
source_type=aws_cloudtrail action=assumerole status=failure
| stats count by user, src_ipWeb auth abuse · 1h · 20 ms
source_type=apache_access (http_status_code=401 OR http_status_code=403)
| stats count by src_ip, uri_pathHunt — multi-filter, 6 to 24 hours
LOLBin to external C2 · 6h · 93 ms
source_type=windows_sysmon action=network_connection (process_name=powershell.exe OR process_name=rundll32.exe)
| stats count by process_name, dest_ip, src_hostFailed-then-succeeded auth · 6h · 79 ms
source_type=windows_event
| stats count(eval(action="logon_failure")) as fails, count(eval(action="logon_success")) as wins by user
| where fails > 30 AND wins > 0Exfil by volume · 6h · 48 ms
source_type=conduit_proxy action=allow
| stats sum(bytes_out) as bytes_out by src_ip, dest_host
| sort -bytes_outDNS-based C2 · 24h · 131 ms
source_type=windows_sysmon action=dns_query
| stats count as queries, dc(dest_host) as uniq_domains by src_hostAdvanced / retro IR — parameter-heavy, multi-stage, 24h to 7d
Compromise then escalate · 24h · 62 ms
source_type=windows_event
| stats count(eval(action="logon_failure")) as fails, count(eval(action="special_privileges_assigned")) as priv_grants by user
| where fails > 100 AND priv_grants > 100Single-host timeline · 6h · 36 ms
src_host="ws-exec-087.corp.local"
| timechart span=1h count by actionAccessDenied spike · 24h · 269 ms
source_type=aws_cloudtrail status=failure
| stats count as denials, dc(action) as uniq_actions by userScoping by source_type (plus action / process_name / field filters) lets ClickHouse prune to the relevant partitions and granules, so even 7-day multi-stage hunts stay sub-second-to-~1.5 s. Window size barely matters once the query is scoped — what hurts is a bare keyword with nothing to prune on.
Concurrency & scaling
To find the saturation point, we ramped concurrent virtual analysts 5 → 10 → 25 → 50 → 100 against a single replica, reached directly via port-forward — deliberately bypassing both the load balancer and the search admission controller. This is a worst-case "one pod, no guardrails" probe.
| Concurrent analysts | Throughput | CH exec p50 | CH exec p95 | Round-trip p95 | Error rate |
|---|---|---|---|---|---|
| 5 | 5.9 req/s | 198 ms | 885 ms | 930 ms | 1.1% |
| 10 | 11.5 req/s | 291 ms | 1.50 s | 1.16 s | 28% |
| 25 | 37.1 req/s | 289 ms | 1.09 s | 728 ms | 69% |
| 50 | 81.9 req/s | 250 ms | 1.20 s | 444 ms | 84% |
| 100 | 171.3 req/s | 248 ms | 1.07 s | 302 ms | 91% |
The important read: server-side query execution stays flat at ~250 ms p50 across the entire ramp. ClickHouse is not the bottleneck. What saturates past ~10 concurrent heavy analysts is a single replica's request admission — and note the round-trip p95 actually drops at high concurrency (930 ms → 302 ms) because shed requests fail fast rather than queuing. The error rate, not latency, is what moves.
This probe intentionally removed the two mechanisms that handle concurrency in production:
- Horizontal scale — the demo runs 4 search replicas behind a load balancer; this test hit one. Cluster capacity scales roughly linearly with replica count.
- Admission control — the search concurrency controller queues ad-hoc queries (with detection rules bypassing the queue entirely) instead of letting a replica shed them. The ramp bypassed it.
So the takeaway is not "nano falls over at 100 users" — it's that a bare replica stays clean to ~5–10 concurrent heavy hunts, and you scale past that by adding replicas and leaving admission control on (both are defaults in a managed deployment).
Appendix: worst-case stress reference
For completeness, here's the ceiling — deliberately pathological queries that no real investigation runs: bare, unscoped keyword tokens and dense-column regex, which force a full-table scan with nothing to prune on.
| Query shape | 6h p50 / p95 | 24h p50 / p95 |
|---|---|---|
Bare keyword (error, denied) | 3.0 s / 9.2 s | 10.9 s / 36.8 s |
Regex over the dense message column | 2.0 s / 10.9 s | 22.3 s / 45.8 s |
| Everything else (scoped stats / timechart / filter / eval) | sub-second p95 | sub-3 s p95 |
This cost is window-bound, not match-bound: error | stats count is a flat ~3 s over 24h regardless of how many rows match (even a token with zero matches takes ~3 s), and drops to 0.21 s at a 1h window. The bare-keyword 24h path is the only thing that full-scans.
- Scope by
source_type(andaction/process_name/ a UDM field) so ClickHouse can prune partitions and granules. - Prefer indexed UDM columns over free-text matching on
message. - Default to the narrowest window that answers the question; widen only when scoped.
Every query in the real-world load test above already does (1) and (3) — which is why they stay fast while the bare-keyword probes don't.
Methodology
- Driver: k6, replaying a 94-query corpus (
benchmarks/k6/search_realistic.js) and a discrete-stage concurrency ramp (benchmarks/k6/search_concurrency_ramp.js). - Auth: read-only JWT via
/api/auth/login; requests hitPOST /api/searchwithtable_viewandskip_field_statsset (the same shape the Search UI sends). - Server-side timing is the engine's reported
execution_time_ms(ClickHouse execution), captured separately from client round-trip so transport limits are distinguishable from engine limits. - Conditions: live tenant under ~200 EPS ingestion with 26 detection rules running — not a quiesced box.