We’ve been using Java 8 in production for about a year now, and while I love the Stream API, there are some gotchas that bit me hard. Here’s what I wish someone had told me when I started.

Gotcha #1: Streams Are Not Reusable

This one got me on day one:

Stream<String> stream = list.stream()
    .filter(s -> s.startsWith("A"));

stream.forEach(System.out::println);  // Works fine
stream.forEach(System.out::println);  // IllegalStateException!

Streams can only be consumed once. If you need to iterate multiple times, collect to a List first or create a new stream. Seems obvious now, but it wasn’t when I was debugging a production issue at 2 AM.

Gotcha #2: Parallel Streams and Thread Safety

Parallel streams are awesome for performance, but they’ll bite you if you’re not careful:

List<String> results = new ArrayList<>();  // NOT thread-safe!

list.parallelStream()
    .filter(s -> s.length() > 5)
    .forEach(results::add);  // Race condition!

Use collect() instead:

List<String> results = list.parallelStream()
    .filter(s -> s.length() > 5)
    .collect(Collectors.toList());  // Thread-safe

I spent two days tracking down a bug caused by this. The ArrayList was occasionally missing elements, and it only happened under load. Fun times.

Gotcha #3: Checked Exceptions

Streams and checked exceptions don’t play nice:

// This won't compile
list.stream()
    .map(s -> new URL(s))  // MalformedURLException
    .collect(Collectors.toList());

You have to wrap it:

list.stream()
    .map(s -> {
        try {
            return new URL(s);
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        }
    })
    .collect(Collectors.toList());

Or create a helper method. Either way, it’s ugly. I’ve seen people suggest using sneaky throws, but that feels like a hack.

Gotcha #4: Performance Isn’t Always Better

Don’t blindly convert all your loops to streams thinking it’ll be faster:

// For small lists, this is actually SLOWER
int sum = smallList.stream()
    .mapToInt(Integer::intValue)
    .sum();

// Traditional loop is faster for < 100 elements
int sum = 0;
for (int i : smallList) {
    sum += i;
}

Streams have overhead. For small collections or simple operations, a traditional loop is often faster. Profile before you optimize.

Gotcha #5: Debugging Is Harder

Try debugging this:

list.stream()
    .filter(s -> s.length() > 5)
    .map(String::toUpperCase)
    .filter(s -> s.contains("ERROR"))
    .collect(Collectors.toList());

Where do you put the breakpoint? How do you see intermediate values? I’ve started using peek() for debugging:

list.stream()
    .filter(s -> s.length() > 5)
    .peek(s -> System.out.println("After filter: " + s))
    .map(String::toUpperCase)
    .peek(s -> System.out.println("After map: " + s))
    .collect(Collectors.toList());

Not elegant, but it works.

The Good Parts

Despite these gotchas, I still love streams. They make code more readable and expressive. Just be aware of the pitfalls:

  • Streams are one-time use
  • Be careful with parallel streams and shared state
  • Checked exceptions are painful
  • Profile before assuming streams are faster
  • Use peek() for debugging

Once you internalize these, streams become a powerful tool. Just don’t treat them as a silver bullet.

What I’m Doing Now

I’ve started creating utility methods for common patterns, especially around exception handling. Also being more selective about when to use parallel streams - they’re great for CPU-intensive operations on large datasets, but overkill for most cases.

Anyone else run into weird stream issues? I’d love to hear about them.