Cross-thread Qt code has a recognizable smell: the business intent is small, but the dispatch machinery around it keeps getting larger.

Suppose a worker thread computes a new visible time range and the plot object on the UI thread needs to apply it. The real intent is simple: "run adjust_t_to_target on the plot's thread."

What often lands in the code is this:

QMetaObject::invokeMethod(
    m_plot,
    "adjust_t_to_target",
    Qt::QueuedConnection,
    new_min,
    new_max);

Nothing here is inherently wrong. The problem is that the call site is doing more than asking for a plot update. It is also making a queueing decision, relying on string-based lookup, relying on runtime argument matching, and leaving receiver-lifetime behavior implicit.

That is manageable once. It is not manageable when the same pattern appears a few hundred times across a large Qt codebase.

The framework gives that dispatch policy one public vocabulary:

vnm::qt::post(context, task);
vnm::qt::call(context, task);

post means "admit this exact-void work to the receiver's event queue and return." call means "run this on the receiver's affinity thread and do not return until its result or exception is available."

Those two operations are deliberately different. Picking one is a statement about whether completion matters.

invokeMethod is not the villain

It is worth being precise here, because Qt itself is better than the usual complaint suggests.

QMetaObject::invokeMethod supports both direct and queued execution. The Qt reference pages for QMetaObject and Qt::ConnectionType are the useful source material for those primitives.

The problem is not that Qt lacks the mechanism. The problem is that raw calls make every call site rebuild the policy: connection type, argument storage, receiver assumptions, cancellation behavior, and exception handling.

The safe-dispatch surface names the application contract once. Callers use the canonical vnm::qt namespace and choose between two questions:

  • Can this work happen later, with no completion signal? Use vnm::qt::post.
  • Must this operation complete, return a value, or propagate an exception? Use vnm::qt::call.
OperationSame affinity threadDifferent threadCompletion surface
vnm::qt::postqueue for laterqueue for lateradmission result only
vnm::qt::callexecute inlinequeue and blockreturn value or exception

The same-thread distinction matters. post is always deferred, even when the caller is already on the receiver's thread. call checks the receiver's actual Qt thread affinity and executes inline when that is the current thread.

post: always deferred, exact void, admission only

The most direct queued member call looks like this:

const vnm::qt::Post_result post_result = vnm::qt::post(
    m_plot,
    &VNM_plot::adjust_t_to_target,
    new_min,
    new_max);

The member pointer and arguments are checked at compile time. Ordinary arguments are decayed into owned storage and consumed once when the event runs. The member must return exactly void; a value-returning operation is a sign that the caller probably needs call.

The callable form follows the same contract:

const vnm::qt::Post_result post_result = vnm::qt::post(
    m_plot,
    [plot = m_plot, range = std::move(range)]() mutable {
        plot->apply_visible_range(std::move(range));
    });

The callable itself must also return exactly void. It is stored once and invoked later as an lvalue.

post is marked nodiscard because its result is part of the operation, not decoration:

switch (vnm::qt::post(
    m_plot,
    &VNM_plot::adjust_t_to_target,
    new_min,
    new_max)) {
case vnm::qt::Post_result::QUEUED:
    break;

case vnm::qt::Post_result::RECEIVER_NULL:
    report_plot_dispatch_failure("plot receiver is null");
    break;

case vnm::qt::Post_result::NO_THREAD_AFFINITY:
    report_plot_dispatch_failure("plot receiver has no thread affinity");
    break;

case vnm::qt::Post_result::SUBMISSION_FAILED:
    report_plot_dispatch_failure("Qt rejected or could not store the work");
    break;
}

This is intentionally more precise than a boolean. A null receiver and a receiver with no thread affinity are ownership errors. A submission failure means task or argument storage failed, or Qt rejected the queued call. None should silently masquerade as success.

The important limitation is what QUEUED means: Qt accepted the event. It does not mean the task ran.

After admission, receiver destruction, explicit removal of posted events, or event-loop shutdown can still cancel the work. post has no channel back to the submitting stack after it returns, so it cannot report that later cancellation. If the caller needs proof of execution, the operation is not fire-and-forget; use call or design an explicit asynchronous completion protocol.

That also explains why "I checked for QUEUED" is not a durability guarantee. It is an admission check and nothing more.

call: completion, results, and exceptions

Use call when the caller needs the target operation to finish:

const Plot_view_state view_state = vnm::qt::call(
    m_plot,
    &VNM_plot::view_state);

If the caller is already on m_plot->thread(), the member runs inline. Otherwise Qt queues it to that affinity thread and the caller blocks until the operation completes, throws, or is cancelled before execution.

The callable form can return a movable value:

const Session_snapshot snapshot = vnm::qt::call(
    m_session_model,
    [model = m_session_model] {
        return model->build_snapshot();
    });

Non-void results move back to the caller. Reference results are rejected: a reference crossing a synchronous thread boundary does not provide a safe ownership or synchronization story. Return a value or an owning handle instead.

Target exceptions propagate to the caller. Dispatch failures throw vnm::qt::Dispatch_error, whose code distinguishes the dispatch failure:

try {
    const Session_snapshot snapshot = vnm::qt::call(
        m_session_model,
        &Session_model::build_snapshot);
    consume_snapshot(snapshot);
}
catch (const vnm::qt::Dispatch_error& e) {
    report_session_dispatch_failure(e.code(), e.what());
}
catch (const Snapshot_error& e) {
    report_snapshot_failure(e);
}

The dispatch error codes cover a null receiver, no thread affinity, submission failure, and cancellation before execution. Exceptions from the target retain their original type; callers handle them as they would for a direct call.

This synchronous behavior is useful, but it is not a shutdown primitive. A cross-thread call is an unbounded wait. The receiver's event loop must keep servicing events until the work executes or Qt cancels it. If that thread is blocked waiting for the caller, has stopped processing events, or is part-way through teardown, the two threads can deadlock.

Do not use cross-thread call during shutdown unless the receiver's owner provides a concrete progress guarantee. When no such guarantee exists, restructure ownership so teardown happens on the owning thread or use an asynchronous protocol with an explicit completion signal.

Raw receivers are a real lifetime contract

Both operations take a raw receiver pointer. The dispatch layer does not pin the QObject's lifetime, and a QPointer check immediately before the call cannot turn concurrent destruction into a safe race.

The required stability window differs:

  • For post, the receiver must remain alive with stable thread affinity from function entry through return.
  • For call, the receiver must remain alive with stable thread affinity throughout the entire synchronous operation.

That contract must come from ownership: a join, a shutdown phase, an owning-thread guarantee, a lock whose design permits this call, or another boundary that actually prevents destruction and moveToThread during the operation.

This is not just a nullability rule. A non-null pointer can still race with destruction or affinity changes. Coordinate the receiver's lifetime first, then dispatch.

Guard other queued objects with QPointer

The raw receiver contract is about the object whose event queue accepts the work. A queued task may also refer to another QObject that is allowed to disappear before execution. That is where a captured QPointer is useful.

Suppose a UI-thread session controller is stable during submission, but an optional overlay may close before the queued update runs:

// Established on the overlay's owner thread while its lifetime is stable.
const QPointer<Session_overlay> overlay_guard{m_overlay};

const vnm::qt::Post_result post_result = vnm::qt::post(
    m_session_controller,
    [overlay = overlay_guard,
     labels = std::move(labels)]() mutable {
        if (!overlay) {
            return;
        }
        overlay->apply_labels(std::move(labels));
    });

Here m_session_controller still needs the normal raw receiver guarantee while post is submitting. The captured guard answers a different question later: does the overlay still exist when the callback runs?

The raw m_overlay pointer must itself be stable while overlay_guard is constructed and while that guard is copied into the lambda. Establish the guard on the QObject's owner thread, or under synchronization that prevents destruction, and keep that boundary through capture construction before posting. Only then can the queued callback use the guard to observe later destruction.

Use this pattern when the guarded object belongs to the callback's thread and its affinity is stable. QPointer validates lifetime; it does not authorize access from the wrong thread and does not make concurrent submission through a raw pointer safe.

Captures must be safe to destroy on arbitrary threads

Queueing transfers more than values. It transfers their eventual destruction.

A task capture, a bound member argument, an intermediate value, a result holder, or a transported exception object may be destroyed on:

  • the submitting or calling thread
  • the receiver thread
  • a thread that removes posted events

Every transported object therefore needs non-throwing, thread-agnostic destruction. Its destructor must not depend on a particular thread's thread-local state. A reference-counted capture also needs scrutiny: the last release may run the pointee's destructor on any of those threads.

Prefer owning values such as QString, QByteArray, std::string, containers, and purpose-built snapshots. Non-owning types such as QStringView, std::string_view, raw spans, and borrowed pointers are safe only when an external lifetime contract guarantees that their storage survives execution or cancellation.

The typed member overloads make ownership the default by decaying and storing ordinary arguments. std::ref() opts back into reference semantics explicitly; the referent must outlive execution or cancellation, and the caller remains responsible for cross-thread synchronization.

Exception behavior is part of the choice

Queued fire-and-forget work has no submitting stack left to unwind into. vnm::qt::post is noexcept, but that guarantee begins only at function entry. Once entered, it converts failures instead of letting them escape:

  • task and argument storage performed inside post, plus submission problems, become a non-QUEUED Post_result
  • target exceptions are contained in the Qt callback and reported by the framework
  • reporting failures are contained too, with a fallback diagnostic

Evaluating arguments and constructing a lambda's captures happen before post is entered and may throw normally. If those expressions can throw, the caller must handle them like any other pre-call work.

The target exception does not escape the receiver's event callback, and it cannot be delivered back to the original caller.

vnm::qt::call has the opposite contract because the caller is still waiting. It propagates the original target exception and uses Dispatch_error for failures in the dispatch itself.

This gives a simple review rule. If code needs to react to a target exception, it needs call or a purpose-built asynchronous result channel. A warning from fire-and-forget work is observability, not recovery.

Typed member calls keep the business intent visible

Both operations accept member-function pointers:

const vnm::qt::Post_result post_result = vnm::qt::post(
    m_plot,
    &VNM_plot::adjust_t_to_target,
    new_min,
    new_max);

const Query_status query_status = vnm::qt::call(
    m_query_model,
    &Query_model::refresh_status,
    source_id);

The compiler verifies that the member belongs to the receiver's class hierarchy and that its arguments and receiver are compatible. The receiver pointer itself must be non-const, even when the selected member function is const. The compile-time const check validates member-call compatibility only; it does not admit a const receiver pointer. No string lookup, Q_ARG, or manual metatype registration is needed merely to carry captured C++ values across the dispatch.

Use a callable when the operation genuinely combines several steps or needs explicit capture policy. Use the member form when the intent is one typed member call; it is shorter and exposes more mistakes at compile time.

What safe dispatch should not replace

The two operations own cross-thread execution policy. They are not substitutes for every Qt event-system primitive.

Genuine timers. Use QTimer when elapsed time, a deadline, repetition, or cancellation by timer identity is part of the requirement. Posting work once is not a timer.

Signal lifecycle connections. A signal-slot connection expresses an ongoing relationship, connection type, automatic disconnection on QObject destruction, and often fan-out to multiple observers. Repeated one-shot dispatches do not encode that lifecycle.

Dynamic QML and metaobject tests. Direct QMetaObject::invokeMethod is appropriate when runtime name lookup is the behavior being used or tested, including interaction with methods owned by a dynamic QML object. Replacing that with a C++ member pointer would change the subject of the test.

The dividing line is intent. For owned C++ cross-thread work whose receiver and callable are known at compile time, use vnm::qt::post or vnm::qt::call. When timing, connection lifecycle, or dynamic metaobject behavior is the feature, keep the Qt primitive that expresses it.


The real payoff is not less typing. It is better code review and better maintenance.

When a reader sees vnm::qt::post, they know the work is exact-void, always deferred, and only admitted to the queue. When they see vnm::qt::call, they know the caller needs completion and may receive a value or exception.

Qt still provides the machinery. The framework turns that machinery into two contracts, and the call site says which contract the work actually needs.