Kafka-Streams
JSON Values
Topic Joining
Backpressure Mechanism
Data Streaming

Kafka-Streams Join 2 topics with JSON values | backpressure mechanism?

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

Joining two Kafka topics in Kafka Streams is mostly about choosing the right key, the right join type, and the right JSON serde. The "backpressure" question is different: Kafka Streams does not expose reactive-streams style backpressure operators, but it does naturally slow intake through Kafka's pull-based consumer model and increasing lag.

First: Make the Join Key Correct

Kafka Streams joins happen on record keys, not on arbitrary fields inside the JSON payload. If your JSON values contain the business ID and the record key does not, you must re-key the stream before joining.

Assume one topic contains user profiles and another contains logins:

java
public record UserProfile(String userId, String name) {}
public record UserLogin(String userId, String loginTime) {}
public record JoinedUser(String userId, String name, String loginTime) {}

If both topics are keyed by userId, the join is straightforward. If not, re-map the key first.

Joining Two JSON Streams

A typical stream-stream join with JSON payloads looks like this:

java
1import org.apache.kafka.common.serialization.Serdes;
2import org.apache.kafka.streams.StreamsBuilder;
3import org.apache.kafka.streams.kstream.Consumed;
4import org.apache.kafka.streams.kstream.JoinWindows;
5import org.apache.kafka.streams.kstream.KStream;
6import org.apache.kafka.streams.kstream.Produced;
7import org.apache.kafka.streams.kstream.StreamJoined;
8
9import java.time.Duration;
10
11StreamsBuilder builder = new StreamsBuilder();
12
13JsonSerde<UserProfile> profileSerde = new JsonSerde<>(UserProfile.class);
14JsonSerde<UserLogin> loginSerde = new JsonSerde<>(UserLogin.class);
15JsonSerde<JoinedUser> joinedSerde = new JsonSerde<>(JoinedUser.class);
16
17KStream<String, UserProfile> profiles = builder.stream(
18    "user-profiles",
19    Consumed.with(Serdes.String(), profileSerde)
20);
21
22KStream<String, UserLogin> logins = builder.stream(
23    "user-logins",
24    Consumed.with(Serdes.String(), loginSerde)
25);
26
27KStream<String, JoinedUser> joined = profiles.join(
28    logins,
29    (profile, login) -> new JoinedUser(profile.userId(), profile.name(), login.loginTime()),
30    JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofMinutes(5)),
31    StreamJoined.with(Serdes.String(), profileSerde, loginSerde)
32);
33
34joined.to("joined-users", Produced.with(Serdes.String(), joinedSerde));

This performs a stream-stream inner join, meaning each pair must have matching keys and timestamps that fall inside the join window.

Understand Which Join You Actually Need

There are three common join families in Kafka Streams:

  • 'KStream to KStream: event-to-event join within a time window'
  • 'KStream to KTable: event joined against the latest table state'
  • 'KTable to KTable: state-to-state join'

If one topic is really reference data such as user profile state, a KTable may be a better model than a second KStream.

That would look more like this:

java
1var profileTable = builder.table("user-profiles", Consumed.with(Serdes.String(), profileSerde));
2var loginStream = builder.stream("user-logins", Consumed.with(Serdes.String(), loginSerde));
3
4var enriched = loginStream.join(
5    profileTable,
6    (login, profile) -> new JoinedUser(login.userId(), profile.name(), login.loginTime())
7);

This is often what people actually want when "joining two topics" where one topic is current entity state.

JSON Handling Strategy

Kafka Streams itself does not care that the payload started as JSON. What matters is that both topics are deserialized into stable Java types or JsonNode values before the join.

Strongly typed models are usually easier to maintain than generic JSON trees because:

  • The join function becomes type-safe
  • Schema drift is easier to detect
  • Tests are easier to read

If your schema changes frequently, a JsonNode-based approach may still be useful, but it pushes more runtime validation into your topology code.

What "Backpressure" Means Here

Kafka Streams does not implement reactive pull signals between operators the way Reactor or RxJava might. Instead, it consumes records from Kafka partitions, processes them, commits progress, and naturally falls behind when it cannot keep up.

In practice, that means:

  • The consumer polls at the pace the application can sustain
  • Input lag grows if processing is slower than arrival rate
  • Kafka retains records, so data is buffered in the broker rather than dropped immediately

That is why people often say Kafka uses "backpressure by lag." It is not a dedicated backpressure API, but it is a real flow-control effect of the pull-based model.

If the Join Falls Behind

When a join-heavy topology slows down, the usual causes are:

  • Expensive JSON serialization or deserialization
  • Too few stream threads
  • Large state stores or slow disks
  • Bad key distribution causing partition hotspots
  • Join windows or grace periods that increase state-store pressure

The fix is usually operational or architectural, not "turn on backpressure." Tune partitioning, serialization, state stores, and thread count first.

Common Pitfalls

The biggest mistake is trying to join on a field inside the JSON value while leaving the Kafka record key unrelated. Kafka Streams joins on keys, so re-keying may be required before the join.

Another issue is choosing a stream-stream join when the second topic is really reference state. In those cases, a stream-table join is often simpler and more correct.

Developers also expect reactive-streams style backpressure controls from Kafka Streams. Kafka Streams handles overload differently: the app slows, lag grows, and Kafka buffers the backlog.

Finally, JSON serdes can become a hidden performance cost. If the topology is lagging, serialization overhead is worth profiling before blaming the join itself.

Summary

  • Kafka Streams joins operate on record keys, so key design comes first.
  • Use typed JSON serdes to keep joins readable and maintainable.
  • Choose between stream-stream and stream-table joins based on the data model.
  • Kafka Streams does not expose reactive backpressure operators; overload shows up as consumer lag.
  • If a join topology falls behind, inspect keys, serdes, partitions, thread count, and state-store pressure.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.