defer and Async Cancellation: The Bug That Outlasted Two New Years
Lesson 15 said that defer, once registered, is guaranteed to run when the exec line leaves the stretch of graph it lives in. That promise has one known exception: when an exec line gets snipped and canceled midway by a concurrency structure like race, the registered defer may be skipped entirely. This page tears down the famous bug report and lays out the rules of self-defense.
1. defer's Promise, and Where It Breaks Down
First, restate defer's deal: the exec line registers the defer as it walks past, and the section catches up when leaving the stretch of graph it lives in — finishing normally, a Break jumping out, ending the function early (return), all of them trigger it. In graphs that never wait, that deal is rock-solid. But Verse has a fourth way of "leaving": being canceled. When a <suspends> (waiting-capable) function loses a race to another branch, it gets snipped right at the node it was waiting on — and multiple community reports confirm that its already-registered defers may never run, not even once.
The earliest complete report was posted on the official forums under the title "Verse defer not always working". The repro pattern is remarkably lean and worth reading line by line:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /Verse.org/Concurrency }
using { /UnrealEngine.com/Temporary/Diagnostics }
defer_trap_device := class(creative_device):
TestEvent:event() = event(){}
Func1()<suspends>:void =
defer:
Print("Exit Func1") # expected: always prints when leaving Func1
Sleep(1.0)
TestEvent.Signal()
Sleep(Inf) # suspend and wait, until race cancels it
OnBegin<override>()<suspends>:void =
race:
Func1()
TestEvent.Await()
Print("race finished") # this line does print
Here is the script: race sets two lines running at once — one runs Func1(), the other waits on TestEvent.Await() for the event to ring (like having Bound an Event Dispatcher and waiting for it to be Called). Func1 sleeps for 1 second, then shouts the event out with Signal() (the equivalent of Calling that dispatcher), and settles into an indefinite Sleep(Inf); the other line's Await() hears the event and finishes instantly, race declares it the winner — and the still-sleeping Func1 line gets snipped and canceled on the spot. By defer's deal, Func1 is being forced to "leave its stretch of graph", so the long-registered Print("Exit Func1") should catch up — but in the log, that line never appears.
2. Unfixed Across the New Year — Twice: A Timeline
This is no isolated fluke; it is a known defect with a ticket number, a status, and repeated confirmations:
- November 2023: the original report is filed, and is soon marked To Do by Epic;
- In the months that follow, more reports of the same family appear — including “defer does not run in specific scenarios” and “nested defers only partially execute”;
- As of October 2025, the status still has not changed to Fixed.
In other words, this bug has calmly sailed past two New Years. For tutorial writers, it is a classic case of documented semantics not matching implemented behavior. For game builders, it is an engineering red line to internalize — especially because race is everywhere in UEFN device code (“countdown vs. player finishes” and “await event vs. timeout” practically run on it); the more branches get canceled, the more chances defer has to break its promise.
3. Self-Defense: Put Critical Cleanup After the race
The conclusion fits in one sentence: in a waiting-capable function that race / rush might cancel, do not entrust critical cleanup to defer. Cleanup logic should move up one level, to the spot after the race section — once the whole race has finished, the nodes wired after it are guaranteed to run:
OnBegin<override>()<suspends>:void =
race:
RunRound() # may be canceled midway
TimeoutClock() # may also be canceled midway
# Whichever branch wins, this always runs once race ends:
Print("Unified cleanup: restore doors, hide UI, reset state")
A handy division of labor: let defer handle the nice-to-have wrap-up (debug logs, harmless visual resets), and let the code after the race handle the skip-it-and-your-save-breaks critical cleanup (unlocking doors, returning items, resetting round state). Even if some future version quietly fixes the bug, this split only makes your code clearer — you lose nothing.
A waiting-capable function (marked <suspends>) is mid-Sleep when race declares it the loser and cancels it. What happens to the defers it already registered?
4. Sources
This page is compiled from the following Epic developer forum threads: