Cost & performance

Beyond SQL benchmarks: the true cost of streaming analytics

An end-to-end comparison of Kafka + Spark + Iceberg, Snowflake, ClickHouse, and Streaming Lake. The real cost of streaming analytics isn't a SQL engine's raw speed — it's the bytes you read, the systems you run, and the price of every query. Here it is, backed by a live ~1 TB demo you can watch below.

~1 TB
dataset · queried on one host
151 ms
terabyte-scale inner join
~2.8×
cheaper — and no S3 at all
1
engine — ingest, store & query

Watch it run: a ~1 TB query demo on a single host

The ratios above come from a compact harness. Here is the same engine on a much larger, in-the-wild dataset, recorded end to end: two event-time tables — Person (62.48B rows, 500 GiB) and Employee (108.72B rows, 500 GiB), ~1 TB together — queried live from a single broker. Nothing is pre-aggregated; every figure on screen (pages pruned, bytes read, latency) is printed by the engine for each query.

Why this scale is the whole point. Those tables are a single day of a topic ingesting at ~5 MB/s — about 500 GB/day. Let it run for a year and each table becomes 500 GB × 365 ≈ 178 TB. Because Streaming Lake prunes to only the pages a query touches, the cost of this one-day query barely moves whether the table holds 500 GB or 178 TB — you pay for the answer, not the archive.

What the video shows — every number measured live

Query over ~1 TBPages prunedData readResultLatency
Point lookup personId range99.8%9,979 / 10,000 pages586 KiB21 rows5 ms
Filter age = 35most pages29 MB20,000 rows152 ms
GROUP BY count/min/max by age · off-heap LSM29 MB50 groups3.6 s
ORDER BY top-20 by salarymost pagespruned set20 rows929 ms
Inner join Person ⋈ Employee · ~1 TB each side98.7%6.6 MB22,000 rows151 ms
5-predicate join ranges + IN-list + bands99.4%7 MB4,000 rows1.05 s

Single broker JVM; GROUP BY and joins are backed by an off-heap LSM store spilling to local NVMe — no Spark, no ETL. A terabyte-scale inner join returns in 151 ms after reading 6.6 MB; the hardest 5-predicate join returns in ~1 s reading 7 MB.

Why Streaming Lake is the big winner

Every system here is excellent at something. What the video makes concrete is that Streaming Lake wins the whole pipeline for fresh, selective analytics — not just one stage of it:

The common thread: everyone else pays to move data into a second system before they can query it, then pays again to read far too much of it. Streaming Lake keeps one copy on BookKeeper, prunes at the 1 MiB page level, and runs the join right next to the storage — so a query over a terabyte touches kilobytes to a few megabytes and finishes in milliseconds. That is the big win: not merely a faster SQL engine, but an architecture where cost tracks the answer, not the dataset.

Pruning, concretely: the second predicate is where it separates

In one line: for a filter off the sort key, Iceberg prunes at the 128 MB row group while Streaming Lake prunes at the 1 MiB page — on every column, exactly. Both engines skip data with metadata before reading it, so the win is in how fine that metadata is and what it can promise. Iceberg's finest equality index — a Parquet bloom — is per row group and probabilistic, and its per-page min/max only prunes the one column a file is physically sorted by. Streaming Lake keeps min/max plus a bloom plus an exact SET(N) on every 1 MiB page of every column, so a selective filter on a non-sorted column still narrows to a 1 MiB page — 128× smaller than the 128 MB row group Iceberg falls back to when its per-page min/max can't prune — and to an exact answer, not a “maybe.”

Why can't Iceberg match that? Two limits. First, min/max only prunes where a column is physically clustered, and a file has one physical order: a single sort key clusters one column tightly, and Z-order spreads that tightness across a few columns but loosens each. Any column that isn't clustered has wide, overlapping per-page ranges, so its min/max says “maybe” everywhere — exactly the age = 35 case. Second, the equality index that would cover a non-clustered column — the bloom — is per column chunk (row group) and probabilistic; there is no per-page bloom and no exact SET(N) in Parquet. So a match narrows, at best, to a 128 MB row group, and for a dense value like age = 35 the row-group bloom matches everywhere and prunes nothing. (A bloom on a sparse, high-cardinality column does let Iceberg drop whole row groups — but still at row-group, never page, granularity.)

Put numbers on it. From the demo, layer a non-sort filter on a key slice: personId 0–1,000,000 and age = 35 over the 500.19 GiB Person table. Both engines prune the personId slice; only Streaming Lake also prunes age down to pages:

Pruning — personId slice + age = 35Kafka + Spark + IcebergStreaming Lake
Prune / read unit1 MB page on a clustered col; else 128 MB row group1 MiB page any column · up to 128× finer
Text / equality indexbloom, any column per row group · probabilisticbloom + exact SET(N) per page
Prune the age = 35 filter?no unsorted min/max & row-group bloom both say “maybe”yes 1,024 of 10,000 pages kept · 8,976 skipped
Read to return the 20,000 rowsthe whole personId slice age can't page-prune · modeled29.1 MiB 89.8% of candidate pages pruned · 152 ms

Same rows back — but on the second (non-sort) predicate Streaming Lake skipped 8,976 of the 10,000 candidate pages and read 29.1 MiB, while Iceberg, clustered on personId, can page-skip that key — and even a bloom on age only works per row group — so it scans the whole personId slice to find the matches. A bloom changes which row groups you open, never how much you read once a matching row is inside one. The index that decides skip vs read lives at the page, on every column, and confirms membership exactly — not at the row group, on the clustered columns, and only probably.

Where ClickHouse specifically breaks down

ClickHouse is a superb scan-and-aggregate engine, but its MergeTree design — one physical sort order per table, indexed only by a sparse key index over 8192-row blocks — leaves four gaps that Streaming Lake's per-page, per-column indexing closes:

In fairness, ClickHouse is excellent at big full-table aggregation scans with a well-chosen sort key — that is its wheelhouse. The point is architectural: for fresh, selective, multi-column analytics on a stream, it forces a second system, a full copy, and a single sort order that Streaming Lake doesn't.

The one number that matters: bytes read

Most lakehouse cost is paid at read time — bytes pulled from storage, decompressed, and pushed through a join. The clearest way to compare architectures is therefore the simplest: how many bytes did you have to read to answer the query?

Same selective join, ~1 TB dataset (500 GB ⋈ 500 GB): Spark + Iceberg read many GB (whole matching partitions, 128 MB row groups) Streaming Lake read 6.6 MB (1 MB pages, pruned before decode)

Reading megabytes where a lakehouse reads gigabytes is not a micro-optimization: fewer bytes read generally means less CPU, less memory, lower latency, and lower cost, all at once. Everything below is how Streaming Lake gets there — and where the claim stops.

What we measured

The workload is the hard case: a selective inner join on fresh data, the query pattern that lakehouses handle worst.

SELECT ... FROM Person p INNER JOIN Employee e ON p.personId = e.personId WHERE p.eventTime IN [t0, t0 + window) -- prune by event time (5-min data ledgers) AND p.age <= 52 -- clustered predicate -> prune pages AND e.salary <= 63000 -- clustered predicate -> prune pages
Dataset~1 TB — Person (500.19 GiB, 62.48B rows) + Employee (500.13 GiB, 108.72B rows), event-time ordered; ~171B rows total
Data-ledger rolloverevery 5 minutes → thousands of 5-minute data ledgers per table, sliced into 1 MiB pages
Page size1 MiB (for reference, Iceberg's default Parquet row group is 128 MiB)
Join build tableoff-heap LSM store, spilling to local NVMe — memory-bounded on a single broker

Metadata (the catalog, per-page min/max + bloom footers, and immutable column segments) is built for the full layout, so pruning runs against the true dataset. Only the pages that survive pruning are decoded and joined, and the off-heap build side spills to a real file on disk — so pruning ratios, join correctness, and the off-heap working set are all measured, not estimated.

Results

Join query (over ~1 TB) Data read Pages pruned Matches Latency
Inner join Person ⋈ Employee, filters both sides 6.6 MB of ~1 TB 98.7% 22,000 151 ms
5-predicate join ranges + IN-list + salary band 7 MB of ~1 TB 99.4% 4,000 1.05 s

Measured live on a single broker JVM over the full ~1 TB dataset. The more selective the query, the more it prunes — the inner join skips 98.7% of candidate pages and reads just 6.6 MB; the five-predicate join skips 99.4% and reads 7 MB. Joins run on an off-heap LSM store spilling to local NVMe — no Spark, no ETL.

Why so little I/O — pruning before you decode

Two design choices do the work:

On the probe side the join is late-materialized: only the join-key column is read per row, and the full row is reconstructed only when a key matches — so non-matching rows never pay to be decoded.

One engine, not five

The other half of the cost story is architectural. The standard stack copies data through five systems before a query can run, and pays for each one continuously:

Producer → Kafka → Spark Streaming → S3 + Iceberg → Spark SQL ▲ ▲ ▲ ▲ brokers always-on storage + shuffle · sort · ETL cluster compaction hash join per query

Streaming Lake keeps a single copy in BookKeeper and serves both pub-sub and analytics from it — no ETL job, no second store, no manifest maintenance, no object-store GET/LIST charges:

Producer → Pulsar → BookKeeper → Segment metadata → Broker query (one durable copy = stream + table + history)

Cost comparison

Modeled on published AWS list prices for a sustained pipeline (~10 MB/s ingest, 7-day hot set, ~1,000 selective joins/day). Streaming Lake numbers are the measured read behavior applied to that infrastructure; the competitor column is modeled from list prices and each system's documented defaults.

 Kafka + Spark + S3 + Iceberg modeledStreaming Lake
Monthly TCO~$7,689~$2,700all data on BookKeeper
Systems to operate51Pulsar + BookKeeper
Object storeS3 requiredIcebergnoneBookKeeper is the store
Data read / selective joinwhole partitions128 MB row groups6.6 MBof ~1 TB
Join working setshuffle across the clusteroff-heap LSMon NVMe, one broker
Query latencyseconds–minutesspin-up · shuffle~1 smeasured
Freshness at query timeafter sink + compactionlive

No object store, by design. Streaming Lake holds the entire dataset on BookKeeper — there is no S3 line at all. At RF-2 (Pulsar's default) on cheap sc1 Cold HDD the total is ~$2,700/mo (~2.8×); on warmer st1 HDD it's ~$4,255 (~1.8×), and RF-3 on st1 is ~$5,420 (~1.4×). Per byte, replicated HDD is dearer than S3's erasure coding — the win is deleting the object store, the ETL, and the query cluster around it, not the storage rate.

What you actually run — line by line

Streaming Lake spends money only where speed is needed: NVMe on the bookie journal and the query-broker spill, cheap Cold HDD (sc1) for the bulk ledger data, and cheap stateless pub-sub brokers. The five-component stack pays for a Kafka cluster, two always-on Spark roles, and object storage on top of each other.

Kafka + Spark + S3 + Iceberg modeled$/moStreaming Lake$/mo
Kafka (MSK) brokers + storage$2,439Pub-sub brokers cheap, stateless · 2 × m5.xlarge$280
Spark Streaming ingest always-on EMR$1,102Query brokers NVMe · runs joins + off-heap spill · 2 × r5.2xlarge$777
S3 storage Iceberg data + metadata$598Bookie compute 3 × m5.2xlarge$841
Iceberg compaction periodic Spark$550Bookie journal NVMe · 3 × 100 GB$24
Spark SQL query per query, 1,000/day$3,000Bookie ledger Cold HDD (sc1) · full 30-day dataset · RF-2 ≈ 52 TB$778
  Query execution on the query brokers above — no per-query clusterincl.
Total~$7,689Total~$2,700

AWS on-demand list prices, US-East-1 (verify current). The competitor side is modeled from list prices and documented defaults; the Streaming Lake side applies the measured read behavior to this hardware. Discounts (reserved / spot, often 30–70%) apply to both. One honest caveat: sc1 is cold HDD — cheapest per byte, but low random IOPS — so keep the fresh/hot working set on faster media (st1 or NVMe + page cache) via bookie affinity groups; cold data served from sc1 answers in seconds, not the ~1 s measured on NVMe.

How it stacks up

A qualitative read of where the selective-join workload lands across four representative systems:

AreaKafka + Spark + IcebergSnowflakeClickHouseStreaming Lake
Streaming ingestionExcellentExternal pipelineGoodExcellent
Fresh-data queryMinutescommit cadenceGoodExcellentExcellent
Predicate pruningRow group · page on clustered colsMicro-partitionsMarks / granulesPage-level, every column
Join executionDistributed shuffleDistributedLocal or distributedBroker-local + off-heap
InfrastructureKafka+Spark+S3+IcebergWarehouseClusterBroker + BookKeeper
Read amplificationMediumMediumLowPotentially very low
Operational complexityHighLow managedMediumMedium

Each system is excellent at what it was built for. ClickHouse in particular remains a best-in-class analytical engine; the distinction here is architectural — Streaming Lake unifies ingestion, storage, indexing, and query on one source-of-truth log.

In one line: Streaming Lake integrates streaming ingestion, storage, indexing, and query execution into a single architecture that minimizes storage reads through page-level pruning — enabling selective analytical joins with small working sets, and without a separate ETL or distributed query tier.

Want to see this on your data and your query shapes? We'll benchmark a selective join against your current stack.

Request early access →