Inline Control Flow: Powerful, Dangerous, and Hungry for Locks
A subtle Kotlin inline quirk let an early return bypass Redis lock cleanup, leaving keys stuck in memory. This is the story of how one misplaced return hijacked an entire cleanup flow and the simple fix that made it bulletproof.

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:

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

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:

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.
The “Just Use a Label” Fix (and Why It’s Fragile)
Kotlin lets you scope the return with a label:

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:

Now cleanup happens always, enforced by the bytecode itself. No labels. No manual guardrails.
Takeaways
- Inline functions aren’t just faster - they rewrite control flow rules.
runCatching
≠ catch-all. It only wraps exceptions, not returns.- Old-school
try-finally
is timeless for resource cleanup.