Inline Control Flow: Powerful, Dangerous, and Hungry for Locks

At Headout, Redis locks are our quiet guardians. They keep sensitive flows like payment confirmations, inventory updates and so much more from colliding. One SETNX with an expiry buys us a low-latency mutex that lives right next to our data.

The pattern shows up so often that we bottled it into a friendly helper function:

Snippet 1: Redis lock wrapper

One call. Automatic cleanup. Developer bliss.
At least… until those locks started overstaying their welcome.

The First Clues

While chasing down what we thought was an unrelated bug in our booking confirmation flow, we noticed a graveyard of Redis keys refusing to leave until they expire, clogging our in-memory DB like forgotten carts in a supermarket or villains in Gotham city.

The culprit? Our old friend withRedisLock.

The Original Design

Snippet 2: Code inside the wrapper function

On paper, it looked foolproof:

  • Try to acquire the lock.
  • Run the critical section.
  • Delete the lock, no matter what happened.

But one subtle Kotlin feature blew right past our cleanup.

The Gotcha: Non-Local Returns in Inline Functions

A consumer looked like this:

Snippet 3: Caller site of our wrapper

That early return doesn’t just exit the lambda, it exits handlePaymentProcessing entirely.
Because withRedisLock is inline, the lambda’s bytecode is copy-pasted into the caller. A RETURN in there jumps right out of the enclosing function, skipping everything that follows in withRedisLock including redis.delete(key).

runCatching can’t save you here; it only catches throwables, and return is a control-flow escape hatch, not an exception.

Code flow with runCatching

The “Just Use a Label” Fix (and Why It’s Fragile)

Kotlin lets you scope the return with a label:

Snippet 4: Potential fix

This works because the flow comes back to the post-lambda cleanup. But it’s highly reliant on the review quality and easy to miss.
One sleepy PR review and a plain return sneaks in… boom, lock leak’s back in production.

Will the Real Fix Please Stand Up

A finally block is special. The JVM guarantees it runs no matter how you leave the try block, be it - a normal return, an exception, or a non-local return from an inline function.

Refactored helper:

Snippet 5: The final fix, we promise

Now cleanup happens always, enforced by the bytecode itself. No labels. No manual guardrails.

Code flow when try and finally are used

Takeaways

  1. Inline functions aren’t just faster - they rewrite control flow rules.
  2. runCatching ≠ catch-all. It only wraps exceptions, not returns.
  3. Old-school try-finally is timeless for resource cleanup.
Shashank Kumar’s Profile Image

written by Shashank Kumar

Engineering solutions by day, exploring stories through films and books by night, and occasionally winning the Travel track at Hackout (okay, just once in 2023, but who's counting?).

Dive into more stories