Back to Stream: Performance & Pitfalls
Q1Question 1 of 5

Stream.peek(): debugging only, may be optimized away, side effects unreliable

Key Concepts
  • peek is an intermediate operation that applies a Consumer to each element and passes it unchanged to the next stage
  • Intended for debugging: inserting log statements to observe what flows through the pipeline
  • The JVM may skip peek entirely if the terminal operation does not need it (e.g., count on a sized source)
  • In parallel streams peek execution order is non-deterministic
  • Short-circuiting terminals like findFirst may never reach later elements
  • Never put business logic, auditing, or persistence in peek: use forEach for terminal side effects
  • The Java specification explicitly states peek exists mainly for debugging purposes

Definition

Stream.peek is a debugging-only intermediate operation that may be optimized away by the JVM, making it unreliable for business logic or side effects.
  • Intermediate operation – applies a Consumer to each element as it passes through, then passes the element unchanged to the next stage.
  • Debugging use – insert .peek(t -> log.debug("stage: {}", t)) between operations to observe pipeline flow.
  • May be skipped – the JVM can skip peek if the terminal operation does not require element traversal (e.g., count() on a SIZED source).
  • Unreliable for side effects – in parallel streams, execution order is non-deterministic; short-circuiting terminals may skip elements.
  • Alternative – use forEach (terminal) for intended side effects, and collect or reduce for results.

peek is like a security camera on a conveyor belt: it observes items passing by but does not change them. However, if the factory manager decides to count items by reading the source manifest instead of running the belt, the cameras never see anything. Do not rely on cameras for quality stamps (business logic).

Understanding the Concepts

What does Stream.peek do?

peek is an intermediate operation that applies a Consumer to each element as it passes through, then passes the element unchanged to the next stage.

JAVA
import java.util.List;
import java.util.stream.Collectors;

List<String> result = List.of("SAV2001", "CUR3001", "FD4001").stream()
    .peek(id -> System.out.println("Before filter: " + id))
    .filter(id -> id.startsWith("SAV"))
    .peek(id -> System.out.println("After filter: " + id))
    .collect(Collectors.toList());
TEXT
Before filter: SAV2001
After filter: SAV2001
Before filter: CUR3001
Before filter: FD4001

Why can peek be skipped by the JVM?

JAVA
// count() on a sized source may skip all intermediate operations
long count = List.of("SAV2001", "CUR3001", "FD4001").stream()
    .peek(id -> System.out.println("peek: " + id))  // may NEVER execute!
    .count();
// The list's size is known (3). count() can return 3 without processing elements.

Why is peek dangerous for business logic?

JAVA
// WRONG: auditing in peek
List<Trade> settled = trades.stream()
    .filter(Trade::isSettled)
    .peek(t -> auditService.record(t))  // BUG: may not run for all elements!
    .collect(Collectors.toList());
// If any optimization skips elements, audit records are incomplete.

// CORRECT: audit in forEach after collecting
List<Trade> settled = trades.stream()
    .filter(Trade::isSettled)
    .collect(Collectors.toList());
settled.forEach(t -> auditService.record(t));  // guaranteed for all elements

What about short-circuiting terminals?

JAVA
// findFirst may skip later elements
Optional<String> first = List.of("SAV2001", "CUR3001", "FD4001").stream()
    .peek(id -> System.out.println("peek: " + id))
    .filter(id -> id.startsWith("SAV"))
    .findFirst();
// peek fires only for SAV2001, not for CUR3001 or FD4001 (short-circuited)

What about parallel streams?

JAVA
// In parallel streams, peek execution order is non-deterministic
List.of("SAV2001", "CUR3001", "FD4001").parallelStream()
    .peek(id -> System.out.println(Thread.currentThread().getName() + ": " + id))
    .toList();
// Output order varies per run

What is the correct use of peek?

peek is for transient debugging only. Insert .peek(t -> log.debug("stage: {}", t)) between operations to observe pipeline flow, then remove it before committing.

JAVA
// Acceptable: temporary debugging
List<Trade> result = trades.stream()
    .filter(Trade::isSettled)
    .peek(t -> log.debug("After filter: {}", t.id()))
    .map(Trade::normalize)
    .peek(t -> log.debug("After normalize: {}", t.id()))
    .toList();
// Remove peek lines after debugging!

What are common mistakes?

JAVA
// WRONG: mutating objects inside peek
trades.stream()
    .peek(t -> t.setStatus("PROCESSED")) // side effect, unreliable!
    .toList();

// WRONG: business logic in peek
trades.stream()
    .peek(t -> emailService.sendConfirmation(t)) // may be skipped!
    .count();

// CORRECT: use forEach for side effects
trades.stream().filter(Trade::isSettled).forEach(t -> emailService.sendConfirmation(t));

What should a senior engineer know?

  • peek is for transient debugging only. Remove it before committing code.
  • In code reviews, flag any peek with business logic as a bug.
  • The behavior of peek with short-circuiting terminals (findFirst, anyMatch, limit) is well-defined but often surprising: only elements actually consumed are peeked.
  • The Java specification explicitly states peek exists mainly for debugging purposes.
  • The JVM can skip peek if the terminal operation does not require element traversal.

Visual Overview

DIAGRAM
  peek reliability:

  ┌─────────────────────────────────────────────┐
  │ stream.peek(log).filter(f).collect(toList) │
  │   peek RUNS: elements flow through pipeline │
  └─────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────┐
  │ stream.peek(log).count()                    │
  │   peek MAY BE SKIPPED: count reads size     │
  │   directly from source metadata             │
  └─────────────────────────────────────────────┘

  Rule: peek = debugging only. forEach = side effects.

Key Takeaway

Stream.peek is a debugging-only intermediate operation that applies a Consumer to each element and passes it unchanged. It is intended for inserting temporary log statements to observe pipeline flow, then removing them.

The JVM may skip peek entirely if the terminal operation does not require element traversal. count() on a sized source can return the count from metadata without processing elements. Short-circuiting terminals like findFirst may never reach later elements. In parallel streams, peek execution order is non-deterministic.

Never put business logic, auditing, or persistence in peek. Use forEach (terminal) for guaranteed side effects and collect or reduce for computing results. In code reviews, flag any peek with business logic as a bug.

Remember one sentence: peek is for debugging only because the JVM may skip it; use forEach for side effects that must execute.

---

Share
Use ← → arrow keys to navigate