Batch or Real Time: A Tradeoff Usually Framed Wrong

The question arrives as a technology question. Should this integration be batch or streaming? And it gets answered like one, by comparing engines, latencies and throughput numbers.

That framing is the problem. Latency is not the requirement. It is a property you buy, and the thing worth arguing about is how much of it you actually need.

Start from the decision, not the pipeline

Data exists so somebody, or something, can act on it. So the useful question is not “how fresh can we make this?” but “how quickly does the action it triggers actually happen?”

Freshness beyond the cadence of the decision it feeds is waste. It costs money and complexity and buys nothing, because the number sits there unread until the weekly review, the nightly credit run, the moment a human opens the dashboard.

That gives a rule worth applying before any engine is chosen:

flowchart TD
    A[New integration] --> B{Does something act<br/>on this automatically?}
    B -- no --> C{How often does a<br/>person look?}
    C -- daily or less --> D[Batch]
    C -- continuously --> E[Micro-batch]
    B -- yes --> F{Is the action reversible<br/>if the data is late?}
    F -- yes --> E
    F -- no --> G[Streaming]

Most integrations land on the left half of that diagram. The ones that genuinely need streaming tend to share a shape: something acts without a human, and acting late is materially worse than not acting.

Fraud declines. Inventory that can oversell. Routing decisions. Anything where the window to respond closes on its own.

The cost you can see

The obvious cost difference is shape, not size. Batch is bursty: nothing, then a spike, then nothing. Streaming is a floor you pay whether or not anything is happening.

BatchStreaming
Compute profileSpiky, schedulableContinuous floor
Failure blast radiusOne runThe live path
RecoveryRe-run itReplay, reprocess, reconcile
Schema changeNext runCoordinated, with in-flight events
Who can debug itMost of the teamWhoever knows the streaming engine

That last row is the one that gets left out of the estimate, and it is often the one that decides whether the system survives its second year.

The cost you find later

The expensive part of real time is not infrastructure. It is that correctness stops being obvious.

In batch, “yesterday’s orders” is a closed set. You know when it is complete because the day ended. In a stream there is no such boundary — you decide one, and every event that arrives after it forces a choice you now have to make explicit.

sequenceDiagram
    participant S as Source
    participant P as Pipeline
    participant C as Consumer
    S->>P: event t=10:00
    S->>P: event t=10:02
    P->>C: window 10:00-10:05 closed
    Note over P,C: consumer acts on the total
    S-->>P: event t=10:01 (arrives 10:07)
    Note over P: late — restate, discard,<br/>or emit a correction?

There is no free answer. Discarding it makes the number quietly wrong. Restating means every downstream consumer has to handle a total that changes after the fact. Emitting a correction means they have to understand corrections.

In batch, the same event simply lands in the next run.

What this looks like in practice

The batch version of a rolling total is a query with a boundary you can point at:

-- Incremental model: the boundary is explicit and inspectable.
select
    customer_id,
    date_trunc('day', ordered_at) as day,
    sum(amount)                   as daily_amount,
    count(*)                      as order_count
from {{ ref('orders') }}
where ordered_at >= (select coalesce(max(day), '1900-01-01') from {{ this }})
group by 1, 2

If it is wrong, you re-run it. The failure mode is a stale number, and stale is a condition you can detect and alert on.

The streaming version has to say what it believes about time:

# The windowing is the easy part. The watermark is the real decision:
# it declares how long the pipeline waits for stragglers before it
# calls a window closed — and therefore which events become "late".
orders = (
    env.from_source(kafka_source, watermark_strategy, "orders")
       .key_by(lambda o: o.customer_id)
       .window(TumblingEventTimeWindows.of(Time.days(1)))
       .allowed_lateness(Time.hours(2))
       .aggregate(SumAmount())
)

allowed_lateness(2 hours) is not a tuning parameter. It is a business statement: after two hours we would rather be wrong than wait. Somebody should agree to that explicitly, and almost nobody is ever asked.

The hybrid trap

The instinct when both are needed is to run both — a fast path for freshness, a batch path for correctness, reconciled downstream. It is a reasonable-sounding design that tends to age badly, because the same business logic now exists twice and drifts.

Jay Kreps made the counter-argument well: if the log is replayable, you can reprocess history through the same code that handles the live stream, and the second implementation stops being necessary.

The argument is worth taking seriously, and worth reading as what it is: a case for avoiding duplicated logic, not a case for making everything streaming.

What I would ask before choosing

  • What acts on this, and how fast does it actually act?
  • If the number were two hours stale, what would go wrong — concretely?
  • Who is on call for this at 3am, and do they know this engine?
  • When a value is restated after the fact, what does the consumer do?
  • If we choose batch now and need streaming in a year, what does that migration cost?

The last one matters more than it looks. Batch to streaming is usually a rewrite. Streaming to batch is usually a simplification. When the answer is genuinely unclear, the asymmetry argues for starting simple.

None of these are big decisions. Each is a sentence in a design document, made in an afternoon, and each one quietly sets what the system can be asked for two years later.

Kafka - Flink - dbt - SQL