Set Timer by Event with the Time pin set to 0.0 will never call the event bound to it. The node itself executes and the execution flow continues normally, but the returned Timer Handle is invalid. If a timer is already associated with that handle, the call clears it first.
Here, zero means “clear the timer.”
The symptom: silence
No compile error. No red node. No runtime exception. Is Valid Timer Handle returns false for the returned handle — assuming you think to check it at all.
The only visible signal is a warning in the Output Log, generated from this format string in Engine/Private/KismetSystemLibrary.cpp:748 (UE 5.8):
FFrame::KismetExecutionMessage(*FString::Printf(
TEXT("%s %s SetTimer passed a negative or zero time. The associated timer may fail to be created/fire! "
"If using InitialStartDelayVariance, be sure it is smaller than (Time + InitialStartDelay)."),
*ObjectName, *FunctionName), ELogVerbosity::Warning);For the graph above, the output looks like this (the PIE path on the second line has been shortened):
LogScript: Warning: Script Msg: BP_TimerZero_C_UAID_74563C6B4F116DF302_1712491287 OnTimerFired SetTimer passed a negative or zero time. The associated timer may fail to be created/fire! If using InitialStartDelayVariance, be sure it is smaller than (Time + InitialStartDelay).
LogScript: Warning: Script Msg called by: BP_TimerZero_C /Memory/UEDPIE_0_…:PersistentLevel.BP_TimerZero_C_UAID_74563C6B4F116DF302_1712491287
LogBlueprintUserMessages: IsValidTimerHandle -> false
The first %s resolves to the actor instance name, and the second resolves to the function bound to the Event pin. The third line comes from the graph’s own Log String: the returned handle is invalid.
What the log does not contain is FIRED. The event never ran.
The phrase may fail to be created/fire is more cautious than the implementation warrants. There is no may here: when Time <= 0, the timer is not created.
#if !NO_LOGGING (Core/Private/Misc/CoreMisc.cpp:411). In a default Shipping build, this warning is not emitted at all. On a test device, the failure is completely silent: the event does not run, and nothing in the log explains why.Where it disappears: one condition in FTimerManager
The call path is short. K2_SetTimerDelegate checks the time only to emit the warning, then forwards the value unchanged (Engine/Private/KismetSystemLibrary.cpp:735):
InitialStartDelay += FMath::RandRange(-InitialStartDelayVariance, InitialStartDelayVariance);
if (Time <= 0.f || (Time + InitialStartDelay) < 0.f)
{
// ... KismetExecutionMessage(..., ELogVerbosity::Warning);
}
FTimerManager& TimerManager = World->GetTimerManager();
Handle = TimerManager.K2_FindDynamicTimerHandle(Delegate);
TimerManager.SetTimer(Handle, Delegate, Time,
FTimerManagerTimerParameters { .bLoop = bLooping, .bMaxOncePerFrame = bMaxOncePerFrame,
.FirstDelay = Time + InitialStartDelay });The actual decision happens one layer deeper, inside FTimerManager::InternalSetTimer (Engine/Private/TimerManager.cpp:653):
if (FindTimer(InOutHandle))
{
// if the timer is already set, just clear it and we'll re-add it, since
// there's no data to maintain.
InternalClearTimer(InOutHandle);
}
if (InRate > 0.f)
{
// ... the entire FTimerData setup, ExpireTime, heap push
}
else
{
InOutHandle.Invalidate();
}The entire timer initialization is guarded by InRate > 0.f. When that condition fails, the function does exactly one thing: it invalidates the handle, with no log and no ensure.
If a timer already existed on the same handle, InternalClearTimer has already run by this point. Setting Time to 0.0 therefore does more than prevent a new timer from being created: it also clears the timer that was already running.
That is easier to hit from Blueprint than the code suggests, because the node has no handle input. K2_FindDynamicTimerHandle resolves the handle from the delegate, so a second call bound to the same event — this time with Time = 0.0 — finds the existing timer and clears it.
The complete behavior matrix follows directly from that condition:
| Input | What the engine does | Signal |
|---|---|---|
Time > 0 | Timer is created normally | — |
Time = 0.0 | Handle is invalidated and any existing timer is cleared | Warning in LogScript |
Time < 0 | Same behavior as zero | Warning in LogScript |
Time = 0.0, InitialStartDelay = 5.0 | Same behavior as zero: the condition checks InRate, while InitialStartDelay affects only FirstDelay | Warning in LogScript |
SetTimer(..., 0.f, ...) from C++ | Handle is invalidated and any existing timer is cleared | No signal at all |
From C++, there is not even that one warning. A direct call to GetWorldTimerManager().SetTimer(...) reaches the same InternalSetTimer implementation but bypasses the Kismet layer entirely. Set Timer by Function Name is not a way out either: a different Kismet wrapper, the same condition underneath.
The condition itself is reasonable. A looping timer with a rate of zero could otherwise create an infinite loop within a single frame.
else branch and a silent Invalidate(). The only warning disappears along with logging.Epic documented it — just not where most users look
This behavior is explicitly documented in the function comment (Engine/Classes/Kismet/KismetSystemLibrary.h:723):
* @param Time How long to wait before executing the delegate, in seconds.
* Setting a timer to <= 0 seconds will clear it if it is set.That line never appears in the node’s main tooltip. The editor stops the function description at the first @param and moves the remaining descriptions to the individual pin tooltips.
Time pin appears when you hover over the pin itself, not the node header. This behavior applies to every BlueprintCallable function in the engine, not just timers. I covered the underlying mechanism separately in A Blueprint node tooltip stops at the first @param.What to do instead
Set Timer for Next Tick by Event node (KismetSystemLibrary.h:738, function K2_SetTimerForNextTickDelegate). It has no time input, so it cannot accidentally be set to zero.When Time comes from data — a Data Asset, a curve, a gameplay attribute, or a value produced by difficulty scaling — validate it before passing it to the node. A Branch is the simplest way to do that: feed the same value into both the condition and the Time pin. If zero and negative values should mean “run on the next tick,” route them to Set Timer for Next Tick by Event and positive values to the regular timer node.
That routing is a decision about the data contract, not a drop-in replacement. Set Timer for Next Tick by Event never loops, so it cannot stand in for a looping timer whose rate happened to arrive as zero. And if a timer is already running on the same handle, the two branches differ in effect: the regular node with a non-positive Time clears that timer, while the next-tick branch leaves it running and adds one extra call to the event. When neither outcome is the intended one, a non-positive value is simply bad data — log it and skip the call, or substitute a known default.
What to check, in order, when a timer never fires:
- Use
Print Stringto display theTimevalue immediately before the node. The value that reaches the node at runtime is the only one that matters. - Run
Is Valid Timer Handleon the returned handle. A result offalsemeans the timer was not created. - Filter the Output Log by the
LogScriptcategory, which can be suppressed inDefaultEngine.ini. - Check whether another call is setting the same timer again with a value of zero, since that call will clear the existing timer.
- In a Shipping build, the absence of a warning does not mean the call succeeded.
When this matters
This starts to matter once Time stops being a hard-coded literal and instead comes from a Data Asset, a gameplay attribute, a difficulty multiplier, a DPS-based interval, or a cooldown scaled by a character stat. In each of these cases, the value can end up being zero through an empty struct, an uninitialized field, a missing data value, or a division that happens to return zero under rare conditions.
And because Time = 0.0 also clears a timer already running on the same handle, there are two possible failure modes instead of one: “it never started” and “it was running, and then it stopped.”