skip to content

Explain the difference between assembly time and subscription time in Reactor. Why does it matter for correctness, and how do defer/fromCallable relate to it?

level: principalimportance: must knowfreq 55%

answer

  1. Nothing happens until subscribe()
  2. Assembly = build blueprint (eager args run now)
  3. Subscription = data flows, lambdas run
  4. Mono.just(call()) runs eagerly once — bug
  5. defer/fromCallable = lazy, per-subscriber; retries re-run

basics

~20 s

Assembly time is when you build the operator chain (the reactive pipeline is just a blueprint). Subscription time is when someone calls subscribe() and data actually flows. Nothing runs until subscription. Mono.defer/Mono.fromCallable delay eager work so it happens per-subscription instead of at assembly.

solid answer

~40 s

Reactor pipelines are lazy blueprints. Assembly time is when the chain of operators is declared/constructed — each operator wraps the previous into a new Publisher, but no data flows and, crucially, code you write directly (not inside a lambda) executes here immediately. Subscription time is when a Subscriber calls subscribe(); only then do operators run their logic, upstream is subscribed, and elements flow. The classic bug: Mono.just(expensiveCall()) executes expensiveCall() eagerly at assembly time — once, when the chain is built — not per subscriber. To defer it, use Mono.fromCallable(() -> expensiveCall()) or Mono.defer(() -> buildMono()) so the work runs lazily at subscription time, and re-runs for each subscriber. This matters for correctness (capturing current time/state, retries actually re-executing, cold vs hot behavior) and for not doing blocking/side-effecting work while merely constructing a pipeline.

code

java · 17 lines
java
// ANTI-PATTERN: runs at assembly time, once, shared by all subscribers
Mono<User> eager = Mono.just(userService.load(id)); // load() already executed!

// FIX 1: fromCallable — supplier runs at subscription time, per subscriber
Mono<User> lazy1 = Mono.fromCallable(() -> userService.load(id));

// FIX 2: defer — rebuild the whole source per subscription (fresh state each time)
Mono<Instant> now = Mono.defer(() -> Mono.just(Instant.now()));

// Why it matters: retry re-subscribes -> lazy work re-executes, eager does not
lazy1.retry(3)              // each attempt actually calls load() again
     .subscribe();

// Assembly-time hook vs runtime: doOnSubscribe fires at subscription time
Flux.range(1, 3)
    .doOnSubscribe(s -> log.info("subscribed - now data flows"))
    .subscribe();

go deeper

for a junior

Should grasp 'nothing runs until subscribe' at a basic level; the eager-argument nuance is advanced.

for a middle

Should explain the two phases and recognize the Mono.just(blockingCall()) anti-pattern.

for a senior

Explains defer vs fromCallable, ties it to retries re-subscribing and per-subscriber freshness, and assembly-time exceptions vs onError.

for a principal

Core target: reasons about cold/hot publishers, subscription-driven re-execution, context capture at subscribe time, and designing pipelines so no side effects leak into assembly.

## Two distinct phases A Reactor `Mono`/`Flux` is a **lazy recipe**, not a running computation. There are two moments to keep separate: 1. **Assembly time** — when you *build* the chain (`Flux.just(...).map(...).filter(...)`). Each operator instantiates a new `Publisher` that references its upstream. No elements flow yet. **However**, any plain expression you pass — evaluated by the JVM to produce an argument — runs **right now**, eagerly, once. 2. **Subscription time** — when a `Subscriber` calls `subscribe()` (in WebFlux the framework does this for you when the HTTP response is written). Now Reactor walks the chain upstream, subscribing operator to operator, and data begins to flow downstream. Operator *logic* (the lambdas in map/filter/flatMap) executes here, per subscription. > Mantra: **"Nothing happens until you subscribe."** Building the pipeline does nothing observable except any eager argument evaluation. ## The canonical bug ```java // BAD: userService.load() runs NOW, at assembly, exactly once Mono<User> m = Mono.just(userService.load(id)); ``` `userService.load(id)` is a normal method call evaluated to produce the argument to `just` — so it executes immediately when this line is *constructed*, regardless of whether anyone subscribes, and its single result is captured forever. Two subscribers get the SAME cached value; a retry re-subscribes but does NOT re-run the call. ```java // GOOD: deferred to subscription time, re-runs per subscriber Mono<User> m1 = Mono.fromCallable(() -> userService.load(id)); Mono<User> m2 = Mono.defer(() -> Mono.just(userService.load(id))); ``` - **Mono.fromCallable(Supplier)** — wraps a (possibly blocking) synchronous call; the supplier runs at subscription time, once per subscriber, and exceptions become onError. - **Mono.defer(Supplier<Mono>)** — postpones building the *inner* Mono until subscription, so the whole sub-pipeline (including any eager expressions inside it) is created fresh per subscriber. Use when the source itself must be constructed lazily (e.g., capture the current timestamp, current SecurityContext, a fresh transaction). - **Flux.defer** is the Flux analog. ## Why it matters for correctness - **Freshness / per-subscription state:** `Mono.just(Instant.now())` fixes the time at assembly; `Mono.defer(() -> Mono.just(Instant.now()))` reads it at each subscribe. - **Retries actually re-execute:** `retry()`/`retryWhen()` work by re-subscribing. If the real work sits in an eager `just(...)`, re-subscription replays the cached value and the retry is useless. With `fromCallable`/`defer`, re-subscription re-runs the work. - **No accidental side effects at construction:** building a controller's pipeline shouldn't fire DB writes or HTTP calls; keeping work in deferred/lambda form ensures it only happens on subscribe. - **Cold vs hot:** cold publishers restart work per subscriber (via deferral); hot publishers (e.g., `Sinks`, `share()`) emit independently of subscription timing — knowing which you have depends on this model. ## Assembly-time errors vs runtime signals An exception thrown while *assembling* (e.g., a NullPointerException constructing an operator argument) blows up synchronously at the call site — it is NOT delivered as onError. Errors that occur at subscription/runtime are delivered through the reactive `onError` channel. This is why you never do risky work in raw arguments; wrap it so it becomes a proper error signal. ## Operators that key off this distinction - `defer` / `fromCallable` / `fromSupplier` — move work to subscription time. - `transform` (assembly, once) vs `transformDeferred` (subscription, per subscriber). - `Mono.cache()` — subscribe once, then replay to later subscribers (turns cold into hot-ish). - `doFirst` / `doOnSubscribe` — hooks that run at subscription time. ## Interview framing State the two phases, give the `Mono.just(blockingCall())` anti-pattern, and show `defer`/`fromCallable` as the fix while tying it to retries and per-subscriber freshness. That demonstrates real operational understanding, not just vocabulary.

  • You wrap a flaky HTTP call as Mono.just(client.call()) and add .retry(3), but retries don't seem to re-hit the server. Why, and how do you fix it?
    client.call() executed once at assembly time and just replays the cached result; retry re-subscribes but there's no work to redo. Wrap it as Mono.fromCallable(() -> client.call()) (or use a reactive WebClient Mono), so each (re)subscription actually re-invokes the call.
  • What's the practical difference between Mono.fromCallable and Mono.defer?
    fromCallable takes a Supplier<T> that returns a plain value at subscription time (good for wrapping a single blocking/sync call). defer takes a Supplier<Mono<T>> and rebuilds an entire inner publisher per subscription — use it when the source itself (and any eager expressions inside it) must be constructed lazily, e.g. to capture current time or context per subscriber.
  • Where does subscription actually happen in a Spring WebFlux controller?
    You return the Mono/Flux; the framework (the reactive HTTP adapter writing the response) subscribes to it. You should not call block()/subscribe() yourself in the request path — returning the publisher lets WebFlux drive subscription at the right time.

saying these in an interview costs you the question

  • Believing Mono.just(call()) defers the call
  • Thinking operators run when the chain is built
  • Not knowing retry works by re-subscribing
  • Confusing fromCallable (Supplier<T>) with defer (Supplier<Mono<T>>)
  • Doing side-effecting work in raw operator arguments

context