Event-Driven vs Polling: Costing Out Two Architectures
"Don't put logic in Event Tick" is advice Unreal has been repeating for decades. This page writes the same requirement both ways and itemizes the bill: per-second cost, response latency, missed events, readability — plus the few cases where polling is still the right call.
1. One Requirement, Two Shapes
The requirement comes from a real question on the Epic developer forums: a patrol loop runs in the background, and pressing a stop button should end it. The beginner instinct is to write break inside the subscribed handler — a dead end, because break can only live inside the loop body, and the handler is a different stretch of code that cannot reach that loop.
That leaves two workable roads. Approach A (polling): the handler only raises a flag, and the loop checks it once per lap.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
patrol_flag_device := class(creative_device):
@editable
StopButton:button_device = button_device{}
var ShouldStop:logic = false
OnBegin<override>()<suspends>:void =
StopButton.InteractedWithEvent.Subscribe(OnStop)
Patrol()
OnStop(Agent:agent):void =
# The handler does exactly one thing: raise the flag
set ShouldStop = true
Patrol()<suspends>:void =
loop:
if (ShouldStop?):
break
Print("Patrolling...")
# Yield every lap, or the handler never gets a chance to run
Sleep(0.0)
Print("Patrol over")
This is exactly how you'd do it in Blueprints: a bool variable plus a Branch in Event Tick. Two details make or break it. The question mark in if (ShouldStop?) reads this logic's value (Lesson 12's failure context); and that Sleep(0.0) in the loop is non-negotiable — as Lesson 23 taught, a loop with no yield point clamps down and never lets go, the handler never runs, and the flag never rises.
Approach B (event-driven): put "patrol" and "wait for the stop signal" into one race and let cancellation happen by itself.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# The Signal / Await of event(t) come from /Verse.org/Concurrency
using { /Verse.org/Concurrency }
patrol_race_device := class(creative_device):
@editable
StopButton:button_device = button_device{}
StopEvent:event() = event(){}
OnBegin<override>()<suspends>:void =
StopButton.InteractedWithEvent.Subscribe(OnStop)
race:
block:
loop:
Print("Patrolling...")
Sleep(1.0)
block:
# This arm quietly waits for the signal; the moment it lands, it wins
StopEvent.Await()
Print("Patrol over")
OnStop(Agent:agent):void =
StopEvent.Signal()
Walk the chain: player presses the button → OnStop runs → StopEvent.Signal() fires → the second arm's Await() catches it and that arm counts as finished → race declares it first → the patrol arm is cut → execution reaches Print("Patrol over"). Lesson 24's cancellation rule is at work here: the losing loop arm is not choked off at the instant of Signal — it runs to its next yield point (that Sleep) before exiting.
Approach B has no flag and no break; "patrol until the stop signal arrives" is almost plain English. This is one of the biggest structural dividends a Blueprint author collects in Verse — Blueprints have no race, so over there only Approach A exists.
2. Itemizing the Bill
| Dimension | Polling (Tick / loop + Sleep) | Event-driven (Subscribe / Await) |
|---|---|---|
| Executions per second | About 30 (the server's simulation-frame rhythm), whether anything happened or not | Once per actual occurrence — possibly twice in a whole match |
| Growth with object count | Linear: twenty doors means twenty exec lines spinning every frame | Constant: a subscription is an attached record, not a per-frame budget item |
| Response latency | Up to one frame late (about 1/30 s) | Runs the moment the event happens |
| Can it miss things? | Yes. It only sees "what the state is now"; press-and-release within one frame is lost | No. An event records "what happened," and none of them slip through |
| Readability | Intent is scattered across a flag, a loop and a handler | "When X happens, do Y" is almost self-documenting |
| Cleanup cost | You reset the flag; you exit the loop | race cancels the loser for you; Await is one-shot and leaves no record |
The most underrated row is "can it miss things." A performance gap can be absorbed by better hardware; missed events are a correctness problem — they show up as "sometimes it just doesn't work," the hardest and most expensive class of bug to reproduce. The root cause is simple: polling samples state, events record change, and state sampling is blind by construction to whatever happened between two samples.
3. When Polling Is Still Right
To be fair: polling isn't a sin, it's just overused. In the cases below, a per-frame loop remains the correct answer.
- Continuous quantities. Interpolation, smooth movement, progress bars, camera follow — these genuinely differ every frame and there is no "event" to wait for. It is exactly why Timeline exists in Blueprints, and since Verse has no Timeline you write
loop+Sleep(0.0)yourself. - Quantities with no event to subscribe to. When something simply doesn't broadcast its changes, checking it yourself is all you have. Best practice then is to drop the polling rate to the minimum the requirement allows (
Sleep(0.25)rather thanSleep(0.0)) instead of maxing out at 30 per second by default. - Short loops with a definite end. A three-second door animation, a five-second countdown —
breakwhen done and leave no stray wire behind. The dangerous kind is the loop that starts at kickoff and never stops.
One test settles most of these decisions for you: ask whether the thing is discrete or continuous. Discrete (pressed, entered, died, picked up) means events; continuous (position, progress, a ticking time display) means a loop. The same test holds in Blueprints — Verse just makes "wait for one thing to happen" so much easier to write that the temptation to abuse Tick shrinks.
In the race approach, when does the patrol loop actually stop after StopEvent.Signal()?
Sources
Compiled from the Epic developer forums and official documentation: