Quick Tip Evergreen

Set Timer by Event with Time = 0.0 never fires

Time = 0.0 on Set Timer by Event does not mean “immediately”. It means “clear the existing timer and do not set a new one.” The node runs without an error, the returned handle is invalid, and the only warning goes to the Output Log. In a default Shipping build, that warning isn't emitted at all.

Table of contents

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.”

Repro: Event BeginPlay calls Set Timer by Event with Time = 0.0 and Looping disabled. The custom event OnTimerFired logs FIRED, and the Return Value passes through Is Valid Timer Handle into Log String — only the second of those two lines ever appears in the log.

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.

Warning
The logging handler is guarded by #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:

InputWhat the engine doesSignal
Time > 0Timer is created normally—
Time = 0.0Handle is invalidated and any existing timer is clearedWarning in LogScript
Time < 0Same behavior as zeroWarning in LogScript
Time = 0.0, InitialStartDelay = 5.0Same behavior as zero: the condition checks InRate, while InitialStartDelay affects only FirstDelayWarning in LogScript
SetTimer(..., 0.f, ...) from C++Handle is invalidated and any existing timer is clearedNo 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.

Insight
The engine deliberately rejects the timer, but communicates that rejection through nothing more than an 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.

Note
The description of the 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

Tip
If you want to run an event as soon as possible without running it inline on the current execution path, use the dedicated 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.

The same value feeds both the <= 0 condition and the Time pin. The true branch goes to Set Timer for Next Tick by Event, while the false branch goes to Set Timer by Event — both are bound to the same OnTimerFired event. The 0.0 literal stands in for a value that would come from data in real code.

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.

Checklist

What to check, in order, when a timer never fires:

  • Use Print String to display the Time value immediately before the node. The value that reaches the node at runtime is the only one that matters.
  • Run Is Valid Timer Handle on the returned handle. A result of false means the timer was not created.
  • Filter the Output Log by the LogScript category, which can be suppressed in DefaultEngine.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.”

See also

Related pages