Engineering Atlas › System Design Fundamentals

Combining batch and speed layers

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The master dataset

All raw events are appended to an immutable store, typically a data lake partitioned by time. Nothing is updated in place. This is the source of truth; every derived view is a function of it, so a view can always be rebuilt. Nathan Marz, who proposed the design in 2011, summarised it as query equals a function of all data, with the real-time layer covering only the hours the batch layer has not yet reached (Marz).

Batch layer

On a schedule (hourly or daily), a batch job such as Spark or a warehouse query recomputes views from the master dataset, or from the newest partitions merged with the previous view. Because it sees all data for the interval, including late arrivals, it can compute exact distinct counts, complex joins, and ML features. The output is written as a new, immutable version of the batch view and swapped in atomically.

Speed layer

A stream processor (Flink, Kafka Streams, Spark Structured Streaming) consumes the same events as they arrive and incrementally updates a real-time view, often in a key-value store such as Redis or Cassandra. It only needs to cover data newer than the last completed batch run. It may use approximations (HyperLogLog for distinct counts; Redis's version uses at most 12 KB per key with 0.81% standard error (Redis docs)) and may drop very late events, because those errors are temporary.

Serving layer and the merge

A query for "views of video V today" reads the batch view up to the batch cutoff time T and the real-time view for events after T, then combines them. When the next batch run finishes, the cutoff moves forward and the corresponding real-time entries are discarded. The merge logic must know T exactly; an off-by-one hour here double counts or drops an hour of data.

The Kappa alternative

Jay Kreps argued in 2014 that keeping one piece of logic in sync across a batch system and a stream processor is the design's unfixable cost. His alternative keeps the raw events in a replayable log; to change the logic, start a second copy of the streaming job from the start of the log, writing to a new table, and switch readers when it catches up (Kreps). The catch is retention: Kappa needs the log to hold as much history as you may ever reprocess, and at Uber's volume Kafka keeps only a few days (Fu and Soman). Kafka's tiered storage, production-ready since 3.9 in November 2024, offloads log data to pluggable external stores such as cloud object storage, which makes long retention much cheaper than broker disks (Apache Kafka). Microsoft's architecture guide now presents Lambda, Kappa and lakehouse designs side by side, naming duplicated logic as Lambda's main drawback (Azure Architecture Center).

Down the stack

Layer 1The contract

For any query time, the answer equals exact batch results for all data before the published cutoff T plus best-effort streaming results after T. Results before T are final unless the batch is rerun; results after T are provisional and may change by a bounded amount once the next batch lands. Data is never counted twice and never missing, provided the serving layer merges on the same T the batch layer published.

Layer 2The mechanism

The batch layer writes each run as a new versioned output, for example views/campaign_hourly/run=2026-10-14T13/, and publishes a small manifest row (view, cutoff_T, location, run_id) only after all files are committed. The serving layer reads the latest manifest row, so the cutoff and the data it describes change together. The speed layer stores per-key, per-minute buckets in a key-value store with keys such as campaign:8812:202610141347 and a TTL a little longer than the batch delay, typically 24 to 48 hours. In Cassandra, each increment is a write to a memtable plus a commit log append, and counters there are not idempotent: a timed-out increment may or may not have been applied, so a retry can over-count. Counter columns also cannot expire, and a counter table may hold only counters outside its primary key (Cassandra docs). That is why many speed layers write absolute values computed by the stream processor as plain columns with upserts and a TTL, instead of increments. A query fans out: one read for the batch view range, one multi-get or range scan for the minute buckets after T, then a sum in the service.

Layer 3The machine

The two layers stress hardware in opposite ways. The batch layer is throughput-bound: hourly jobs scan hundreds of GB sequentially from object storage at 100 MB/s per connection across many connections, and their cost is CPU hours. The speed layer is latency- and IOPS-bound: 300,000 events per second become tens of thousands of key-value writes per second after pre-aggregation, each an append to the commit log, which on an SSD with fsync batched every 10 ms sustains easily, but each read for a dashboard must touch up to 100 minute-buckets, so the speed store's p99 read latency, often 5 to 20 ms, dominates the query. Two separate clusters also means two sets of memory, disks, and network paths to provision and patch.

Why it's built this way

  1. Exact computation over all data is slow, so it runs in batch on a schedule.
  2. Users want fresh numbers, so a second, incremental path covers the gap since the last batch.
  3. Incremental code is harder to get right and cannot run every algorithm, so its results are allowed to be approximate and are overwritten.
  4. Overwriting is only safe if the master data is immutable and the cutoff is explicit, so both are core to the design.

Diagram

flowchart LR
  EV["Event stream"] --> MD["Master dataset (immutable)"]
  EV --> SP["Speed layer: stream processor"]
  MD --> BL["Batch layer: recompute"]
  BL --> BV["Batch views up to cutoff T"]
  SP --> RV["Real-time views after T"]
  BV --> SV["Serving layer: merge"]
  RV --> SV
  SV --> Q["Query"]
  BL -.->|"new cutoff T"| RV

Worked example

An ad platform reports impressions and unique users per campaign. It receives 300,000 impressions per second at peak, about 15 billion per day.

The speed layer is a Flink job keyed by campaign_id, keeping per-minute counters and a HyperLogLog sketch (12 KB each, about 0.8 percent error) for unique users. With 200,000 active campaigns, the state is roughly 2.4 GB of sketches plus counters, well within a 10-node cluster. Results go to Cassandra as absolute per-minute values (not counter columns, which cannot expire) with a 48-hour TTL.

The batch layer runs hourly at 15 minutes past the hour on the previous hour's partition in the lake. It computes exact impression counts after fraud filtering, which uses a model too heavy to run per event in the stream, and exact distinct users using a full COUNT(DISTINCT). Fraud filtering typically removes 3 to 6 percent of impressions.

A dashboard query at 14:40 for today reads batch views for 00:00 to 13:00 (the 13:00 to 14:00 batch has not finished yet) and the speed view for 13:00 to 14:40. The advertiser sees a number that is exact up to 13:00 and slightly inflated for the last 100 minutes, because the speed layer cannot apply the fraud model. When the 13:00 batch lands, the cutoff moves and the number drops by a few percent for that hour. The UI must explain this correction, or advertisers will file tickets.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Lambda (batch plus speed)Exact recomputation, real-time freshness, robust to stream bugsTwo codebases, merge complexity, double infrastructureBatch logic cannot run in the stream, or approximations are fine short-term
Kappa (stream only, replay to fix)One codebase, one set of semanticsReprocessing history through the stream can be slow and costlyLogic expressible in streaming, log retention is affordable
Batch onlySimplest, cheapest, exactHours of latencyNobody acts on data faster than the batch interval
Unified engine (Beam, Spark, Flink SQL)Same code for batch and stream runsEngine-specific semantics leak; not all logic ports cleanlyYou want Lambda behaviour without two codebases

Pitfalls in production

Leadership lens

Check yourself

  1. Why can the speed layer afford to be approximate or lossy?

    Show answer

    Its results are temporary; every interval is eventually recomputed exactly by the batch layer and the real-time values for it are discarded.

  2. What exactly must the serving layer know to merge correctly?

    Show answer

    The precise cutoff of the most recent completed batch view, so it reads batch data before that point and real-time data only after it.

  3. What is the main argument for replacing Lambda with Kappa?

    Show answer

    Maintaining two implementations of the same logic is costly and they diverge; if the stream processor can reprocess history by replaying the log, one path suffices.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →