In 2019, a major US retailer ran nightly ETL jobs to update inventory counts. A viral TikTok video drove 140,000 orders for a single product in 90 minutes. By the time the batch pipeline caught up the next morning, the company had oversold by 23,000 units, triggering $4.2 million in refunds, expedited shipping for rerouted orders, and a customer trust hit that took two quarters to recover from.
That story is not unusual. Every organization that runs on batch pipelines has a version of it — the fraud that was not caught for 12 hours, the recommendation engine serving yesterday's model to today's users, the dashboard that executives stopped trusting because it was always a day behind.
Batch pipelines made sense when data was slow. In 2026, data is fast, and waiting for a nightly ETL run is a competitive disadvantage. This guide is a deep technical walkthrough of streaming-first architecture built on Apache Kafka and Apache Flink — the two technologies that have become the backbone of real-time data infrastructure at companies like Netflix, Uber, LinkedIn, and Stripe.
1. Why Real-Time Matters: Use Cases Where Latency Equals Revenue
Before diving into architecture, let us be honest about when real-time matters and when it does not. Not every pipeline needs sub-second latency. The goal is not to stream everything but to identify the use cases where latency directly impacts business outcomes.
High-impact real-time use cases:
- Fraud detection: Every second of delay in flagging a fraudulent transaction is another transaction that goes through. Visa processes 65,000 transactions per second and must make accept/reject decisions in under 100 milliseconds.
- Dynamic pricing: Ride-sharing surge pricing, airline yield management, and e-commerce flash sales all require real-time demand signals. A 15-minute delay in pricing updates can cost a mid-size e-commerce company $50,000–$200,000 per incident.
- Inventory and supply chain: Real-time stock levels prevent overselling. Real-time logistics tracking enables same-day delivery routing optimization.
- Personalization: A user who just added running shoes to their cart should see sock recommendations immediately, not after the next batch scoring run.
- Operational monitoring: Detecting a spike in 500 errors 30 seconds after it starts versus 30 minutes later is the difference between a brief degradation and a full-scale outage.
- Financial markets: Algorithmic trading systems operate on microsecond latencies. Even back-office settlement systems have moved from T+2 to T+1 and are heading toward real-time settlement.
Ask yourself: "If this data arrived one hour later, would it cost us money, lose us a customer, or create a compliance risk?" If the answer is yes, that pipeline should be real-time. If the answer is "it would be mildly inconvenient," batch is fine.
Use cases where batch is still correct: Monthly financial reporting, annual compliance audits, historical trend analysis for board presentations, data warehouse backfills, and most training data preparation for ML models. Do not over-engineer. The goal is streaming where it matters, not streaming everywhere.
2. Kafka Architecture Deep Dive
Apache Kafka is not a message queue. It is a distributed, immutable, append-only commit log with strong ordering guarantees within partitions. Understanding this distinction is critical because it changes how you design systems on top of it.
Topics, Partitions, and Offsets
A topic is a named stream of records. Think of it as a category — orders, page-views, inventory-updates. Each topic is divided into partitions, which are the unit of parallelism in Kafka.
Each partition is an ordered, immutable sequence of records. Every record in a partition gets a sequential ID called an offset. Offsets are never reused and never decrease. This is the key property that makes Kafka different from traditional message queues: consumers can rewind to any offset and replay data from any point in time (within the retention window).
Figure 1: Kafka cluster architecture — producers write to topic partitions, consumers in a group each read from assigned partitions
Consumer Groups and Rebalancing
A consumer group is a set of consumers that cooperate to read from a topic. Kafka guarantees that each partition is read by exactly one consumer in a group. This is how Kafka scales consumption: add more consumers (up to the number of partitions) and each one handles a smaller share of the data.
When a consumer joins or leaves a group, Kafka triggers a rebalance — partitions are reassigned among the remaining consumers. This is where many teams run into their first production issues. During a rebalance, consumption pauses. If rebalances happen frequently (due to slow consumers, network issues, or excessive GC pauses), throughput drops dramatically.
Best practices for stable consumer groups:
- Set
max.poll.interval.mshigh enough to handle your slowest processing path - Use
session.timeout.msof 30–45 seconds (not the default 10s) for cloud environments with variable network latency - Prefer cooperative sticky rebalancing (
CooperativeStickyAssignor) over the default eager protocol — it avoids the "stop-the-world" rebalance where all partitions are revoked - Monitor consumer lag with tools like Burrow or Kafka's built-in
consumer_lagmetric - Never have more consumers than partitions — excess consumers sit idle
Partitioning Strategy
Partitioning is the most consequential design decision in Kafka. The partition key determines which records end up on the same partition, which means it determines ordering guarantees and processing parallelism.
Common partition key strategies:
- User ID: All events for a user go to the same partition. Good for user-level aggregations. Risk: hot partitions if some users generate disproportionate traffic.
- Order ID: All events in an order lifecycle go to the same partition. Good for order processing pipelines.
- Tenant ID: In multi-tenant systems, ensures all data for a tenant is co-located. Simplifies tenant-level processing.
- Round-robin (null key): Distributes records evenly across partitions. Maximizes throughput but loses ordering guarantees. Use when ordering does not matter.
Increasing the number of partitions after a topic is in production changes the key-to-partition mapping, which breaks ordering guarantees for in-flight data. Plan your partition count based on peak throughput projections, not current load. A good rule of thumb: 1 partition per 10 MB/s of expected throughput, with a minimum of 6 partitions for any production topic.
3. Schema Evolution: Avro vs. Protobuf vs. JSON Schema
In a streaming system, producers and consumers are decoupled. They deploy independently, which means they evolve independently. Without schema management, a producer changing a field name or type will silently break every downstream consumer.
The Schema Registry (Confluent, Apicurio, or AWS Glue) solves this by versioning schemas and enforcing compatibility rules at write time.
| Format | Binary Size | Schema Evolution | Human-Readable | Best For |
|---|---|---|---|---|
| Avro | Compact | Excellent (built-in) | No | Kafka-native, data pipelines |
| Protobuf | Very compact | Good (field numbers) | No | gRPC services, polyglot stacks |
| JSON Schema | Large | Basic | Yes | Debugging, low-throughput topics |
Our recommendation: Use Avro for Kafka topics in production. It was designed specifically for schema evolution in data pipelines, supports forward and backward compatibility checking, and its compact binary format reduces network and storage costs by 40–60% compared to JSON.
Compatibility modes you should know:
- BACKWARD: New schema can read data written with the previous schema. This is the default and the safest choice. You can add fields with defaults, remove optional fields.
- FORWARD: Previous schema can read data written with the new schema. Useful when consumers deploy before producers.
- FULL: Both backward and forward compatible. The strictest mode, but the safest for independent producer/consumer deployments.
// Avro schema example with evolution-safe design
{
"type": "record",
"name": "OrderEvent",
"namespace": "com.example.orders",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "customer_id", "type": "string"},
{"name": "total_cents", "type": "long"},
{"name": "currency", "type": "string", "default": "USD"},
{"name": "items", "type": {"type": "array", "items": "string"}},
// Added in v2 - has default, so backward compatible
{"name": "shipping_method", "type": "string", "default": "standard"},
// Added in v3 - nullable with null default
{"name": "promo_code", "type": ["null", "string"], "default": null}
]
}
4. Stream Processing with Flink: Stateful Computation, Windows, and Watermarks
Apache Flink is a distributed stream processing engine that treats streams as its primary data abstraction. Unlike Spark Streaming (which processes micro-batches), Flink processes events one at a time with true streaming semantics. This matters because it means lower latency, more natural window semantics, and better support for event-time processing.
The Core Abstractions
DataStream API is the primary programming interface. You define a pipeline as a directed acyclic graph (DAG) of transformations: source → map → filter → keyBy → window → aggregate → sink.
// Flink: real-time fraud score per customer
DataStream<Transaction> transactions = env
.addSource(new FlinkKafkaConsumer<>(
"transactions", new TransactionSchema(), kafkaProps));
DataStream<FraudAlert> alerts = transactions
.keyBy(Transaction::getCustomerId)
.window(SlidingEventTimeWindows.of(
Time.minutes(5), Time.minutes(1)))
.aggregate(new FraudScoreAggregator())
.filter(score -> score.getRiskLevel() > 0.85)
.map(score -> new FraudAlert(score));
alerts.addSink(new FlinkKafkaProducer<>(
"fraud-alerts", new AlertSchema(), kafkaProps));
Windows: The Heart of Stream Aggregation
Windows are how you group unbounded streams into finite chunks for aggregation. Flink supports four window types:
- Tumbling windows: Fixed-size, non-overlapping. Example: count orders per 1-minute interval.
- Sliding windows: Fixed-size, overlapping. Example: average transaction amount over the last 5 minutes, updated every 30 seconds.
- Session windows: Dynamic size based on activity gaps. Example: group all user clicks within a session (no gap longer than 30 minutes).
- Global windows: A single window per key that never closes. You provide a custom trigger to fire computations.
Watermarks and Event Time
In the real world, events arrive out of order. A transaction that happened at 10:00:01 might arrive at your Flink job at 10:00:05 because of network delays. Watermarks are Flink's mechanism for handling this.
A watermark is a timestamp that says: "I believe all events with timestamps up to this point have arrived." When a watermark passes the end of a window, the window fires. Events that arrive after the watermark are called late events, and you can configure how to handle them (drop, redirect to a side output, or update the already-fired window result).
// Watermark strategy: allow up to 10 seconds of lateness
WatermarkStrategy<Transaction> strategy = WatermarkStrategy
.<Transaction>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((event, timestamp) ->
event.getEventTimestamp())
.withIdleness(Duration.ofMinutes(1));
Set watermark delay too low and you miss late events. Set it too high and your results are delayed. Start with the 99th percentile of your observed event lateness, then tune based on production metrics. Most systems land between 5 and 30 seconds.
State Management
Flink's killer feature is managed state. Unlike stateless transformations (map, filter), operations like windowed aggregations, joins, and pattern matching require maintaining state across events. Flink handles this transparently: state is stored locally on each task manager, periodically checkpointed to durable storage (S3, HDFS, GCS), and automatically restored on failure.
State backends:
- HashMapStateBackend: State lives in JVM heap memory. Fast but limited by available RAM. Good for small state sizes (< 5 GB per task manager).
- EmbeddedRocksDBStateBackend: State lives in RocksDB on local disk, with a memory cache. Can handle terabytes of state per task manager. Use this for production workloads with large state.
5. Exactly-Once Semantics: Why It Is Hard and When You Actually Need It
The exactly-once guarantee means that each record is processed exactly one time, even in the face of failures. This sounds simple. It is not. There are three levels of message delivery guarantee:
- At-most-once: Fire and forget. Records may be lost. Fastest, simplest.
- At-least-once: Records are never lost but may be processed multiple times. Requires idempotent consumers or deduplication.
- Exactly-once: Each record is processed exactly once, with no duplicates and no losses. Requires coordination between the stream processor and the sink.
Kafka + Flink achieve exactly-once through a combination of mechanisms:
- Kafka idempotent producer: Guarantees that retried sends do not create duplicates within a partition. Enabled with
enable.idempotence=true. - Kafka transactions: Atomic writes across multiple partitions. The producer writes records and offsets in a single transaction.
- Flink checkpointing: Periodically snapshots the entire pipeline state (including Kafka consumer offsets) to durable storage. On failure, the pipeline restarts from the last checkpoint.
- Two-phase commit sink: Flink's Kafka sink participates in Flink's checkpoint protocol, only committing Kafka transactions when a checkpoint completes.
Exactly-once adds 10–30% latency overhead due to checkpoint barriers and transaction coordination. Many production systems use at-least-once with idempotent sinks instead. If your sink supports upserts (like a database with a unique key), at-least-once is effectively exactly-once from the consumer's perspective, without the overhead.
When you truly need exactly-once: Financial transactions (double-charging customers), billing event processing, inventory count updates, ledger systems. When at-least-once is sufficient: Analytics events, log aggregation, recommendation updates, search index building.
6. The Lambda vs. Kappa Architecture Debate
This debate has been raging since 2014, and in 2026, we have enough production experience to declare a winner (with caveats).
Figure 2: Lambda vs. Kappa architecture — Kappa's single-layer simplicity has won for most modern use cases
The Lambda Architecture maintains two parallel processing paths: a batch layer for complete, accurate results and a speed layer for approximate, real-time results. The serving layer merges both views. The theory is sound, but the practice is painful: you maintain two codebases implementing the same business logic, and the merge layer is a constant source of bugs and inconsistencies.
The Kappa Architecture uses a single stream processing layer for everything. Reprocessing is done by replaying from Kafka (which retains data for configurable periods, from days to forever with tiered storage). The same Flink job handles both real-time and historical data.
Kappa won because of three technology shifts that happened after 2020:
- Kafka tiered storage: Kafka can now retain data indefinitely by offloading old segments to object storage (S3, GCS). You no longer need a separate data lake for historical data.
- Flink's batch mode: Flink can process bounded (batch) datasets using the same streaming operators. One codebase, two execution modes.
- Table formats (Iceberg, Delta, Hudi): These provide ACID transactions on top of object storage, making it possible to serve both streaming sinks and ad-hoc queries from the same storage layer.
7. Change Data Capture: Debezium Patterns for Real-Time Database Sync
Change Data Capture (CDC) is the pattern of capturing row-level changes from a database and streaming them as events. It is the bridge between the transactional world (PostgreSQL, MySQL, MongoDB) and the streaming world (Kafka).
Debezium is the dominant open-source CDC platform. It reads the database's transaction log (WAL in PostgreSQL, binlog in MySQL) and produces a Kafka event for every INSERT, UPDATE, and DELETE.
Figure 3: CDC pipeline with Debezium — database changes flow through Kafka to multiple downstream consumers in real time
Why CDC with Debezium instead of application-level event publishing?
- Zero application code changes: Debezium reads the database log. Your application does not need to know about Kafka.
- Guaranteed capture: Every committed transaction is captured, including those from ad-hoc SQL scripts, migrations, and other tools that bypass your application layer.
- Transaction ordering: Events are ordered by their commit timestamp in the WAL, preserving the exact sequence of changes.
- Before-and-after: Each event includes the row state before and after the change, enabling audit logs and temporal queries downstream.
Common CDC patterns:
- Database replication to a data warehouse: Replace nightly ETL with real-time CDC. Your analytics dashboard is always current.
- Cache invalidation: When a row changes in PostgreSQL, invalidate the corresponding Redis cache entry immediately.
- Search index sync: Keep Elasticsearch in sync with your primary database without dual-write bugs.
- Cross-service data propagation: In a microservices architecture, propagate changes from one service's database to others without tight coupling.
8. Backpressure, Lag Monitoring, and Operational Observability
Streaming systems are always-on. Unlike batch jobs that run and finish, a Flink pipeline runs 24/7/365. Operational observability is not optional — it is a core requirement.
Backpressure
Backpressure occurs when a downstream operator cannot keep up with the rate of incoming records. Flink handles this automatically by slowing down upstream operators, but sustained backpressure is a sign that your pipeline is under-provisioned or has a performance bottleneck.
Diagnosing backpressure:
- Flink's web UI shows backpressure per operator as a ratio from 0 (none) to 1 (fully backpressured)
- The bottleneck is usually the first operator in the chain that shows high backpressure
- Common causes: slow sink (database cannot keep up), insufficient parallelism, skewed partitioning (one key gets most of the traffic), GC pauses
Consumer Lag
Consumer lag is the difference between the latest offset in a Kafka partition and the offset the consumer has processed. It is the single most important metric for a Kafka-based pipeline.
- Steady lag near zero: Healthy. Processing keeps up with production.
- Steady lag above zero: Processing is slower than production. Will grow over time. Needs more consumers or faster processing.
- Spike then recovery: Temporary burst (flash sale, deployment). Normal if it recovers within your SLA.
- Monotonically increasing lag: Emergency. Pipeline is falling behind and will never catch up. Scale immediately.
For any Kafka + Flink pipeline, alert on these four metrics: (1) Consumer lag per partition, (2) Flink checkpoint duration, (3) Flink backpressure ratio, (4) End-to-end latency (event timestamp to sink write timestamp). Everything else is secondary.
9. Cost at Scale: Confluent vs. Self-Hosted vs. Redpanda
Kafka infrastructure cost is the number one reason streaming projects get killed after the POC. A proof-of-concept on three brokers costs $500/month. A production cluster handling 1 GB/s with 7-day retention and 3x replication costs $15,000–$50,000/month depending on your choices.
| Option | Monthly Cost (1 GB/s) | Ops Burden | Best For |
|---|---|---|---|
| Confluent Cloud | $18,000–$35,000 | Low (managed) | Teams without Kafka expertise, fast time-to-production |
| Amazon MSK | $12,000–$25,000 | Medium | AWS-native teams, existing AWS infrastructure |
| Self-hosted Kafka | $8,000–$15,000 | High | Large teams with Kafka SRE expertise |
| Redpanda (self-hosted) | $5,000–$10,000 | Medium | Cost-sensitive, Kafka-API compatible, no JVM |
| Redpanda Cloud | $10,000–$20,000 | Low | Managed + cost-efficient, serverless option |
Redpanda deserves special attention. It is a Kafka-API-compatible streaming platform written in C++ (no JVM), which means lower tail latencies, simpler operations (no ZooKeeper, no JVM tuning), and 30–50% lower infrastructure costs at the same throughput. Redpanda has become the default choice for new streaming deployments at startups and mid-size companies since 2024.
Cost optimization strategies:
- Tiered storage: Keep recent data on fast SSDs, archive to S3/GCS. Reduces storage costs by 80% for long-retention topics.
- Compression: Use LZ4 for low-latency topics, zstd for throughput-optimized topics. Typically reduces message size by 60–75%.
- Right-size partitions: Over-partitioning increases overhead (more open files, more replication traffic). 6–12 partitions per topic is sufficient for most workloads.
- Consumer-side batching: Fetch multiple records per poll to amortize network overhead. Set
fetch.min.bytesandfetch.max.wait.msto batch intelligently.
10. Testing Streaming Pipelines: Unit, Integration, and Chaos
Testing streaming pipelines is harder than testing batch jobs because streams are unbounded, stateful, and time-dependent. Here is a three-layer testing strategy:
Unit Tests: Test Your Logic, Not the Framework
Extract business logic into pure functions and test them independently. A fraud scoring function should be testable without Kafka or Flink:
// Testable business logic - no framework dependencies
public class FraudScorer {
public double score(Transaction tx, CustomerHistory history) {
double score = 0.0;
if (tx.getAmount() > history.getAvgAmount() * 3) score += 0.3;
if (tx.getCountry() != history.getHomeCountry()) score += 0.2;
if (history.getTransactionsLastHour() > 10) score += 0.3;
if (tx.isMerchantFirstSeen()) score += 0.2;
return Math.min(score, 1.0);
}
}
// Unit test - fast, deterministic, no infrastructure
@Test
void highAmountFromNewCountryFlagsAsFraud() {
var history = new CustomerHistory("US", 50.00, 2);
var tx = new Transaction(500.00, "RO", true);
assertThat(new FraudScorer().score(tx, history))
.isGreaterThan(0.85);
}
Integration Tests: Flink's MiniCluster and Testcontainers
Flink provides a MiniClusterExtension for JUnit that runs a local Flink cluster in-process. Combined with Testcontainers (which spins up Kafka and PostgreSQL in Docker), you can test your complete pipeline end-to-end:
@ExtendWith(MiniClusterExtension.class)
class FraudDetectionPipelineTest {
@RegisterExtension
static KafkaContainer kafka = new KafkaContainer("7.5.0");
@Test
void detectsFraudulentTransactionEndToEnd() {
// Produce test events to Kafka
produceTransactions(kafka, List.of(
normalTransaction(),
fraudulentTransaction()
));
// Run pipeline
var results = runPipeline(kafka.getBootstrapServers());
// Verify exactly one fraud alert was produced
assertThat(results).hasSize(1);
assertThat(results.get(0).getRiskLevel())
.isGreaterThan(0.85);
}
}
Chaos Testing: What Happens When Things Break
Streaming pipelines must handle failures gracefully. Test these scenarios:
- Broker failure: Kill a Kafka broker. Verify consumers rebalance and no messages are lost.
- Task manager crash: Kill a Flink task manager. Verify the job restarts from the last checkpoint.
- Network partition: Simulate a split-brain between Kafka brokers. Verify ISR (in-sync replicas) behavior.
- Clock skew: Inject events with wildly out-of-order timestamps. Verify watermark and late event handling.
- Schema change: Deploy a producer with a new schema version. Verify consumers handle it correctly.
70% unit tests (business logic in pure functions), 20% integration tests (MiniCluster + Testcontainers), 10% chaos/load tests (production-like environment). Do not invert this pyramid. Teams that skip unit tests and only do end-to-end integration tests have slow, flaky test suites that nobody trusts.
11. Migration Path: From Batch to Streaming Without a Big Bang
Rewriting your entire data infrastructure from batch to streaming is a multi-year project that will likely fail. Instead, use the strangler fig pattern: wrap the existing batch system, gradually route pipelines to the streaming layer, and decommission batch components one at a time.
-
Week 1–2: Set Up CDC
Deploy Debezium connectors for your primary databases. Start streaming changes to Kafka without changing any downstream consumers. This is a no-risk first step because existing batch pipelines continue to work unchanged. -
Week 3–4: Dual-Write to Streaming Sink
Pick one pipeline (the one with the clearest real-time value, like an operational dashboard) and build a streaming version alongside the batch version. Run both in parallel and compare outputs. This is your validation phase. -
Week 5–6: Shadow Mode
Route real traffic to the streaming pipeline but keep the batch pipeline as the source of truth. Compare results at every checkpoint. When the streaming pipeline matches batch output for two weeks straight, it is ready. -
Week 7–8: Cutover and Monitoring
Switch the operational dashboard to the streaming pipeline. Keep the batch pipeline running in read-only mode as a fallback for 30 days. Monitor consumer lag, latency, and data quality metrics aggressively. -
Month 3+: Expand and Decommission
Repeat steps 2–4 for the next pipeline. Each migration gets faster because the Kafka and Flink infrastructure is already in place. After all critical pipelines are migrated, decommission the batch scheduler and its associated infrastructure.
A mid-size fintech (120 engineers, $40M ARR) migrated from a 200+ Airflow DAG batch pipeline to Kafka + Flink over 9 months using this pattern. Results after migration: dashboard latency dropped from 24 hours to 30 seconds, fraud detection time decreased from 6 hours to 800 milliseconds, and the team decommissioned 14 EC2 instances used for batch processing, saving $11,000/month in infrastructure costs. Total migration cost including engineering time: approximately $280,000 — paid back in 8 months through reduced infrastructure and fraud losses alone.
Common migration mistakes to avoid:
- Big bang rewrite: Trying to migrate everything at once. Migrate one pipeline at a time.
- Skipping the dual-write phase: Without comparing batch and streaming outputs, you will not catch bugs until production.
- Under-investing in observability: Build dashboards for consumer lag, checkpoint duration, and end-to-end latency before going live.
- Ignoring schema evolution: Set up Schema Registry and define compatibility rules before your first production topic.
- Over-partitioning: Starting with 100 partitions because "we might scale." Start with 6–12 and increase when you have data showing you need it.
Where to Start
If you are building a new data pipeline today, here is the shortest path to production:
- Start with Redpanda or Confluent Cloud — do not self-host Kafka until you have a dedicated SRE team.
- Use Avro + Schema Registry from day one — schema migration pain is exponentially worse the longer you wait.
- Deploy Debezium for your primary database — CDC is the lowest-risk, highest-reward entry point to streaming.
- Start with Flink SQL for your first streaming job — it is more accessible than the DataStream API and handles most aggregation use cases.
- Build observability first — consumer lag, checkpoint duration, backpressure, and end-to-end latency dashboards should be live before your first production event.
Real-time data infrastructure is no longer a luxury for companies with 500+ engineers. The tooling has matured, the managed services have gotten cheaper, and the patterns are well-documented. The question is not whether you can afford to build streaming pipelines. It is whether you can afford not to.
Ready to Modernize Your Data Infrastructure?
Sumvid Solutions helps companies migrate from batch to streaming-first architectures. Our data engineers have built Kafka and Flink pipelines processing billions of events daily. Get a free architecture review and migration roadmap.
Book a Free DART ROI Blueprint Call