Event-driven interfaces frequently generate more events than an application should process. A single user interaction can initiate repeated validation, rendering, loading, filtering, cache invalidation, and master-detail operations.

EvQueue implements a Calibrated Event Debounce Pattern for WinForms. Instead of executing every intermediate event, it waits until the event stream becomes stable and then performs one meaningful operation. Its defining characteristic is that the debounce interval is selected according to the cost of the protected operation.


The Problem: Technical Events Are Not Always User Intent

WinForms controls can raise several events during one logical interaction.

For example, navigating rapidly through a grid may produce:

CurrentCellChanged
CurrentCellChanged
CurrentCellChanged
CurrentCellChanged
CurrentCellChanged

Each event may trigger a larger application chain:

  • Selection-state recalculation
  • Status validation
  • Command enablement
  • Master-detail refresh
  • Cache invalidation
  • Grid repainting
  • Layout recalculation
  • Loader execution

Most intermediate selections have no business value. They represent rows through which the user passed, not the row the user ultimately selected.

Processing every notification immediately creates unnecessary work and can make the interface appear unstable. The architectural problem is therefore not event delivery—it is distinguishing temporary technical signals from stable user intent.


Pattern Definition

Element Description
Name Calibrated Event Debounce
Implementation EvQueue
Category Behavioral UI coordination pattern
Intent Convert a burst of related events into one deferred operation
Context Event-driven interfaces where intermediate states have little business value
Result The operation executes after the event stream becomes quiet
Key characteristic The interval reflects the expected cost of the operation

The principle is:

Process the stable state produced by the events, not every event that occurred while reaching that state.

Each call to Start() stops and restarts an internal timer. When no additional call arrives during the configured interval, the action executes.

Event 1 → Start()
Event 2 → Start() resets the timer
Event 3 → Start() resets the timer
Event 4 → Start() resets the timer

No additional event arrives
    → Timer expires
    → Action executes once

This is a trailing-edge debounce strategy with latest-state-wins semantics.


Why the Pattern Is Calibrated

A conventional debounce utility commonly uses one fixed delay. EvQueue defines several intervals and selects among them according to the expected operational cost.

The framework defines the following delay policy in ITSHWF\EvQueue.cs:

public static class DebInt
{
    public const int TS = 150;

    public const int AutoSize = 150;

    public const int Form = 150;

    public const int Delayed = 500;

    public const int Slowest = 250;

    public const int Slow = 200;

    //default
    public const int Medium = 150;

    //Status Val Internal
    public const int Fast = 100;
}
Interval Value Typical operation
Fast 100 ms Status validation and command enablement
Medium 150 ms General UI events and list changes
Slow 200 ms Selection changes and deferred loading
Slowest 250 ms Column search and expensive visual refresh
Delayed 500 ms Heavy processing or I/O
AutoSize 150 ms Layout recalculation
Form 150 ms Form-level enablement

The values are framework defaults rather than universal constants. The important design decision is that the cost category is explicit and centralized.

Cheap operations remain nearly immediate. Operations that may invalidate caches, repaint complete grids, or initiate data access receive more time to stabilize.


The Infrastructure Cost of Event Noise

An event is inexpensive only when its handler is inexpensive. In a real enterprise application, one notification may trigger validation, binding updates, cache invalidation, sorting, filtering, layout, repainting, logging, or a database request. When a single user gesture raises the same logical signal repeatedly, the application pays for that entire chain repeatedly—even though most intermediate results are immediately discarded.

This redundant work increases CPU utilization and memory pressure. Repeated object allocation also creates additional garbage-collection activity, while unnecessary painting and layout consume UI-thread time. Database calls and service requests add work to downstream systems as well. As the event rate grows, the message loop has less time to process input and render the final state, so the interface becomes less responsive precisely when the user is interacting with it most actively.

Event noise is not merely a user-experience problem. It is duplicated computation, and duplicated computation consumes infrastructure.

The effect is especially important when WinForms is delivered through Remote Desktop Services, virtual desktop infrastructure, or a cloud-hosted application environment. The event handlers still execute on the hosted Windows session, so every redundant validation, query, and repaint consumes CPU and memory from shared cloud resources. Visual changes may also produce additional screen updates that must be encoded and transmitted by the remote-display protocol.

Across many concurrent sessions, small inefficiencies multiply. Higher sustained CPU usage reduces the number of users a host can support, increases contention between sessions, and may require larger virtual machines or more application hosts. Autoscaling can preserve responsiveness, but it does so by allocating additional billable infrastructure. A noisy application can therefore be more expensive to host than an application that performs the same business work once per stable user action.

The typical progression is:

More technical events
    → more handler executions
    → more CPU, allocation, rendering, and I/O
    → longer UI-thread queues and resource contention
    → slower response times
    → fewer users per host
    → larger or additional cloud resources
    → higher operating cost

EvQueue breaks this chain near its source. It does not make the protected operation cheaper; it prevents the application from performing that operation for transient states the user never intended to keep. The result is lower burst CPU usage, fewer unnecessary data operations, less UI-thread contention, and more predictable capacity requirements.

The savings depend on what the handler actually does. Debouncing a trivial property assignment will have little infrastructure impact. Debouncing a chain that repaints a large grid, recalculates status, invalidates caches, and reloads data can materially improve responsiveness and hosting density. The interval should therefore be calibrated through profiling and load testing rather than treated as a substitute for measuring performance.


Core Implementation

EvQueue provides a non-generic wrapper for actions that do not require a parameter:

public class EvQueue : EvQueueT<object>
{
    public EvQueue(
        Control c,
        Action actn,
        int interval = DebInt.Medium)
        : base(c, _ => actn(), interval)
    {
    }

    public void Start()
    {
        Start(null);
    }
}

The generic implementation stores the owner control, deferred action, timer, and most recently submitted parameter:

using System;
using System.Diagnostics;
using System.Windows.Forms;

public class EvQueueT<T> : IDisposable
{
    private readonly Control _control;
    private readonly Action<T> _action;
    private readonly System.Windows.Forms.Timer _timer;

    private T _param;
    private string _Id;
    private bool _disposed = false;

    public EvQueueT(
        Control c,
        Action<T> actn,
        int interval = 100)
    {
        _control = c;
        _action = actn;

        _timer = new System.Windows.Forms.Timer
        {
            Interval = interval
        };

        _Id = DateTime.Now.ToString("yyyyMMddHHmmss.fff");

        _timer.Tick += Timer_Tick;

        // Auto-dispose when control is disposed.
        if (_control != null)
        {
            _control.Disposed += Control_Disposed;
        }
    }

    private void Control_Disposed(
        object? sender,
        EventArgs e)
    {
        Dispose();
    }

    private void Timer_Tick(
        object? sender,
        EventArgs e)
    {
        _timer.Stop();
        IsDebouncing = false;

        if (_control == null ||
            _control.IsDisposed ||
            _control.Disposing)
        {
            return;
        }

        if (!_control.IsHandleCreated)
            return;

        try
        {
            _action.Invoke(_param);

            Debug.WriteLine(
                $"Action executed for " +
                $"{_control.Parent?.Name}." +
                $"{_control.Name} {_Id}");
        }
        catch (ObjectDisposedException)
        {
            // Control was disposed during action execution.
        }
        catch (InvalidOperationException)
        {
            // Control handle was destroyed during action execution.
        }
    }

    public int Interval
    {
        get => _timer.Interval;
        set => _timer.Interval = value;
    }

    public void Start(T param)
    {
        IsDebouncing = true;

        _timer.Stop();
        _param = param;
        _timer.Start();
    }

    public void Cancel()
    {
        _timer.Stop();
        IsDebouncing = false;
    }

    public bool IsDebouncing { get; set; }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        if (disposing)
        {
            if (_control != null)
            {
                _control.Disposed -= Control_Disposed;
            }

            _timer.Tick -= Timer_Tick;
            _timer?.Stop();
            _timer?.Dispose();
        }

        _disposed = true;
    }
}

This source implementation combines three responsibilities:

  1. Debouncing: every Start() restarts the timer.
  2. Latest-value coalescing: _param is replaced by the most recent value.
  3. Lifecycle coordination: the queue disposes itself when its owner control is disposed.

How the Execution Model Works

The critical method is Start(T param):

public void Start(T param)
{
    IsDebouncing = true;

    _timer.Stop();
    _param = param;
    _timer.Start();
}

Stopping and restarting the timer means the action cannot execute while events continue arriving faster than the configured interval.

The most recent parameter replaces the previous one. Therefore, this implementation does not preserve every submitted value.

The timer callback first stops the timer:

private void Timer_Tick(
    object? sender,
    EventArgs e)
{
    _timer.Stop();
    IsDebouncing = false;

    // Lifecycle validation...

    _action.Invoke(_param);
}

The queue consequently performs one operation for the final stable state.

This makes EvQueue a single-slot coalescing queue, not a traditional first-in, first-out queue.


Why System.Windows.Forms.Timer Is Used

The deferred actions normally interact with WinForms controls. A System.Windows.Forms.Timer operates through the WinForms message loop, so its Tick handler executes in the UI context.

This avoids routine cross-thread control access and removes the need to call Invoke merely to update a control.

However, the timer does not make the operation asynchronous. The action still executes on the UI thread.

EvQueue reduces how frequently an operation executes. It does not reduce how long the operation takes.

A synchronous action performing substantial CPU work or blocking I/O will still freeze the interface during its execution.


Application Examples

Grid Selection Debounce

The framework creates two queues inside GridViewV:

public GridViewV() : base()
{
    _HighlightClCache =
        new Dictionary<DataGridViewColumn, bool>();

    _EvQFindCl = new EvQueue(
        this,
        UpdateClStrToHighLight,
        DebInt.Slowest);

    _EvQCurrentSelectionChanged = new EvQueue(
        this,
        OnCurrentSelectionChangedEvQDelayed,
        DebInt.Slow);
}

Selection processing uses the Slow interval because changing the active row can initiate cache clearing, status recalculation, observers, and master-detail loading.

The native event updates inexpensive visual state immediately but queues the expensive selection chain:

protected override void OnCurrentCellChanged(EventArgs e)
{
    // SelectionChanged occurs before CurrentCellChanged.
    // A SelectionChanged handler accessing CurrentCell
    // can therefore receive its previous value.
    base.OnCurrentCellChanged(e);

    _CellPntr?.InvalidateRow();
    _CellPntr = new CellPntr(this);

    _EvQCurrentSelectionChanged.Start();

    UpdateCellPreview();
}

This separation is significant:

  • Immediate work maintains visual responsiveness.
  • Deferred work handles the stabilized business state.

When the user moves through ten rows rapidly, _EvQCurrentSelectionChanged.Start() may execute ten times, but OnCurrentSelectionChangedEvQDelayed() executes once after navigation stops.


Processing the Stable Selection

After the debounce interval, the grid compares the final row and cell pointers:

protected virtual void OnCurrentSelectionChangedEvQDelayed()
{
    Debug.WriteLine($"_CellPntrEvSel: {_CellPntrEvSel}");
    Debug.WriteLine($"_CellPntrEvSel.Cl: {_CellPntrEvSel?.Cl}");
    Debug.WriteLine(
        $"_CellPntrEvSel.IsSameRW: " +
        $"{_CellPntrEvSel?.IsSameRW()}");
    Debug.WriteLine(
        $"_CellPntrEvSel.IsSame: " +
        $"{_CellPntrEvSel?.IsSame()}");

    CellRWPntr c = new CellRWPntr(this);

    try
    {
        if (ForceActiveRWsChange ||
            !_CellPntrEvSel.IsSameRW())
        {
            OnSelectedRowChangedDelayed();
            OnSelectedCellChangedDelayed();
            OnActiveRWsChange();
            return;
        }

        if (!_CellPntrEvSel.IsSame())
        {
            OnSelectedCellChangedDelayed();
            return;
        }
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.ToString());
    }
    finally
    {
        ForceActiveRWsChange = false;
        _CellPntrEvSel = c;
    }
}

The delayed handler can now decide whether the row changed, only the cell changed, or no meaningful selection change occurred. Expensive row-level work is therefore reserved for an actual stable row transition.


Column Search and Highlighting

Column highlighting can require regular-expression processing, cache inspection, scrolling, and full grid invalidation.

The property stores the escaped search value and starts the search queue:

public string HighlightClStr
{
    get
    {
        return _HighlightClStr;
    }

    set
    {
        string highlightClStr =
            value == null
                ? string.Empty
                : Regex.Escape(value);

        if (_HighlightClStr == highlightClStr)
            return;

        _HighlightClStr = highlightClStr;

        HighlightCl();
    }
}

public void HighlightCl()
{
    _EvQFindCl.Start();
}

Because _EvQFindCl uses DebInt.Slowest, typing several characters rapidly does not trigger a complete search and repaint after every keystroke. The search runs after the input stabilizes.


List-Change Aggregation

Data binding may raise multiple ListChanged events during one operation. The framework records the affected rows and change types, then defers processing:

private void QueueListChangedEventArgs(
    ListChangedEventArgs e)
{
    try
    {
        if (e.ListChangedType == ListChangedType.Reset)
        {
            _LstListChangedPending.Clear();

            _LstListChangedPending.Add(
                this,
                ListChangedType.Reset);

            return;
        }

        Func<int, object> gDGVR = ix =>
            ix >= 0 && ix < Rows.Count
                ? (object)Rows[ix]
                : this;

        switch (e.ListChangedType)
        {
            case ListChangedType.ItemDeleted:
                _LstListChangedPending.AddOnce(
                    gDGVR(e.OldIndex),
                    ListChangedType.ItemDeleted);

                _EvQListAddedOrRemoved.Start();
                break;

            case ListChangedType.ItemAdded:
                _LstListChangedPending.AddOnce(
                    gDGVR(e.NewIndex),
                    e.ListChangedType);

                _EvQListAddedOrRemoved.Start();
                break;

            case ListChangedType.ItemChanged:
                _LstListChangedPending.AddOnce(
                    gDGVR(e.NewIndex),
                    e.ListChangedType);
                break;
        }
    }
    finally
    {
        _EvQListChangeEv.Start();
    }
}

The pending dictionary preserves the meaningful change information, while the queue collapses repeated notifications into one grouped processing cycle.

This illustrates an important distinction:

  • EvQueue coalesces execution.
  • A separate collection retains any details that must not be lost.

Status Validation

The framework creates a medium-speed queue for recalculating status:

public WFValidator(Control c)
{
    if (c != null && c is IStatusValidatorWF val)
    {
        val.StatusValidator?.Cancel();
        val.StatusValidator?.Clear();
    }

    C = c;

    _EvQUpdateStatus = new EvQueue(
        C,
        UpdateStatus,
        DebInt.Medium);
}

Instead of immediately recalculating state, the validator starts the queue:

public override void PerformUpdate()
{
    _EvQUpdateStatus.Start();
}

The design protects status evaluation from bursts of row, cell, and binding changes.


Command Enablement

Command enablement has a separate, faster queue:

protected void InitEnaQueue()
{
    if (_EvQEna != null)
        return;

    Enas = new List<IEna>();

    _EvQEna = new EvQueue(
        C,
        () => Enas.ForEachIn(a => a.Ena()),
        DebInt.Fast);

    StatusValidator.RowChangedInternal -=
        StatusValidator_RowChangedInternal;

    StatusValidator.RowChangedInternal +=
        StatusValidator_RowChangedInternal;
}

When the status row changes, the framework queues command reevaluation:

private void StatusValidator_RowChangedInternal(
    object sender,
    DataRowChangeEventArgs e)
{
    if (Enas.Count == 0 || Master != null)
        return;

    _EvQEna.Start();
}

This creates a two-stage reactive chain:

UI events
    → medium debounce
    → status recalculation
    → computed expressions change
    → fast debounce
    → commands enable or disable

The second debounce prevents one status update from causing repeated toolbar and command-state evaluations.


Deferred Loader Execution

Loader execution is queued only after the control and its parameters pass validation:

protected bool QueueLoadCmm()
{
    if ((!Enabled) || !ValidatePars())
        return false;

    EvQLoad.Start();

    return true;
}

The loader queue uses the slower selection-oriented interval:

EvQLoad?.Cancel();

EvQLoad = new EvQueue(
    this,
    ExecLoader,
    DebInt.Slow);

This prevents repeated loader execution while several parameters are changing in quick succession.

The queue also replaces any previous queue instance after cancelling it, ensuring that an obsolete pending execution does not continue into the new configuration.


Deferred Layout Recalculation

Layout operations are also protected:

EvQAdjustSize = new EvQueue(
    this,
    () =>
    {
        if (D == null)
            return;

        V.SetSize();
    },
    DebInt.AutoSize);

The application starts the queue whenever a size recalculation may be necessary:

private void SetSize()
{
    EvQAdjustSize.Start();
}

Scrolling, toolbar visibility changes, row additions, and nested control updates can all generate repeated layout requests. Deferring V.SetSize() allows the visual hierarchy to settle before calculating its final dimensions.


Architectural Characteristics

EvQueue Is Not a Traditional Queue

Despite its name, EvQueue does not provide FIFO behavior.

It does not:

  • Preserve every event
  • Guarantee delivery of every parameter
  • Maintain an ordered backlog
  • Support durable processing
  • Execute every submitted operation

The following sequence:

queue.Start(first_value);
queue.Start(second_value);
queue.Start(third_value);

results in one execution with third_value, assuming all calls occur before the timer expires.

This is intentional. EvQueue is appropriate when the application cares about the final stable state rather than the complete event history.

A real queue is required when every item represents independent business work.


Debounce Versus Throttle

Mechanism Behavior
Debounce Waits for activity to stop and then executes
Throttle Executes periodically while activity continues
FIFO queue Preserves and processes every item
EvQueue Debounces repeated signals and retains the latest parameter

For search input, selection changes, deferred validation, and final layout calculation, debounce is normally appropriate.

For continuous progress reporting or periodic updates during an active operation, throttling is more appropriate.


Lifecycle Management

The queue subscribes to the owner control’s Disposed event:

if (_control != null)
{
    _control.Disposed += Control_Disposed;
}

The event delegates to Dispose():

private void Control_Disposed(
    object? sender,
    EventArgs e)
{
    Dispose();
}

Disposal detaches both event handlers and releases the timer:

protected virtual void Dispose(bool disposing)
{
    if (_disposed)
        return;

    if (disposing)
    {
        if (_control != null)
        {
            _control.Disposed -= Control_Disposed;
        }

        _timer.Tick -= Timer_Tick;
        _timer?.Stop();
        _timer?.Dispose();
    }

    _disposed = true;
}

Associating the queue lifecycle with the control avoids leaving active timers after a form or component has been destroyed.


Anti-Patterns and Risks

Anti-pattern detected: silent exception suppression

The source timer callback suppresses ObjectDisposedException and InvalidOperationException without logging:

catch (ObjectDisposedException)
{
    // Control was disposed during action execution
}
catch (InvalidOperationException)
{
    // Control handle was destroyed during action execution
}

These exceptions may represent an expected disposal race, but suppressing every exception of those types can conceal defects inside the deferred action itself.

For example, _action.Invoke(_param) could throw an unrelated InvalidOperationException. The current implementation would treat it as a destroyed control handle and silently discard it.

A safer implementation uses exception filters and records the expected lifecycle race:

try
{
    _action.Invoke(_param);
}
catch (ObjectDisposedException ex)
    when (_control.IsDisposed || _control.Disposing)
{
    Debug.WriteLine(
        $"EvQueue cancelled during disposal: {ex.Message}");
}
catch (InvalidOperationException ex)
    when (!_control.IsHandleCreated)
{
    Debug.WriteLine(
        $"EvQueue control handle was destroyed: {ex.Message}");
}

Unexpected exceptions are then allowed to propagate to the application’s normal error-handling mechanism.


Anti-pattern detected: externally mutable execution state

The source exposes:

public bool IsDebouncing { get; set; }

External code can set the property without starting or stopping the timer, producing a state value that no longer represents actual queue behavior.

The setter should be private:

public bool IsDebouncing { get; private set; }

Risk: expensive work remains on the UI thread

Using System.Windows.Forms.Timer is appropriate for actions that directly update controls. It is not appropriate for long synchronous operations.

This code remains problematic even when debounced:

var queue = new EvQueue(
    this,
    () => ExecuteLongRunningDatabaseOperation(),
    DebInt.Delayed);

The operation may run only once, but it will still block the UI thread while running.

The pattern should defer the trigger, while asynchronous or background infrastructure performs the expensive work.


Risk: pending state is intentionally discarded

Because only the last parameter is retained, EvQueueT<T> must not be used for:

  • Audit events
  • Financial transactions
  • File-processing jobs
  • Messages requiring guaranteed delivery
  • Commands where order matters
  • Independent database updates

Those scenarios require a real work queue or message-processing mechanism.


Best Practices

  1. Use Fast for inexpensive status and command-state evaluation.
  2. Use Slow or Slowest for operations that may invalidate caches, repaint complete controls, or initiate loading.
  3. Keep the deferred UI action short.
  4. Store required event details separately when aggregation must preserve more than the final value.
  5. Cancel pending execution when parameters or ownership change.
  6. Tie queue disposal to the owning control.
  7. Treat interval values as performance policy and centralize them.
  8. Measure actual operation cost before increasing delays.
  9. Do not use debounce where every event carries independent business meaning.

Conclusion

EvQueue is a small implementation with a significant architectural role.

It separates noisy WinForms notifications from meaningful application operations. Each Start() represents a technical signal, while the eventual timer callback represents the stable state the application should process.

Its principal strengths are:

  • Repeated events are coalesced.
  • The latest state wins.
  • Delay intervals are calibrated by operational cost.
  • Actions execute in the WinForms UI context.
  • Pending operations can be cancelled.
  • Timer lifetime follows the owner control.
  • The same mechanism applies consistently to grids, validators, loaders, forms, filters, and layout.

The central principle remains:

React to what the user ultimately did, not to every intermediate event generated while the user was doing it.

EvQueue does not eliminate events. It converts event noise into meaningful work.