A Publish is a client-to-server request that the server parks. Nothing ever reaches DataChanged unless a Publish request is already sitting in that session's queue when the subscription's publishing timer expires. There is no server-initiated push in OPC UA. That single fact reorders the whole diagnostic: before touching Lifetime, find out whether the client is still feeding the queue.
Where does the update actually stop?
Follow the packet from the node value to the callback. Each hop has its own timer and its own failure signature, and they look nothing alike on the wire.
| Hop | Governed by | Signature when it stops here |
|---|---|---|
| Node value → MonitoredItem queue | SamplingInterval, deadband filter, QueueSize / discardOldest | Keep-alives and some notifications still arrive; individual values never appear |
| MonitoredItem queue → publish cycle |
PublishingInterval, PublishingEnabled
|
Notifications arrive batched or, with publishing disabled, only keep-alives arrive |
| Publish cycle → parked Publish request | Outstanding Publish requests on the session | Total silence; keep-alive counter and lifetime counter both climb server-side |
| PublishResponse over TCP 4840 | Secure channel, session timeout, firewall/NAT idle timeout | Abrupt silence, later Bad_SecureChannelClosed or Bad_SessionIdInvalid
|
SDK dispatch → DataChanged
|
Client callback thread | Handler entered late or not at all; the publish loop stalls behind it |
UaExpert holding the same nodes on the same endpoint clears the first two hops and the server's address space, security policy, and NodeIds. What it does not clear is your client's publish pipeline and callback thread — hops three and five.
What does the server do with Lifetime = 10000 and MaxKeepAliveTime = 10000?
Both properties are milliseconds in the client API, but the protocol carries counts. CreateSubscription sends RequestedPublishingInterval, RequestedMaxKeepAliveCount, and RequestedLifetimeCount, where the counts are your millisecond values divided by the publishing interval the server actually grants. The server answers with revised values, and those are what it enforces. CurrentLifetime is the read-back of that revision.
Two counters run on every publishing cycle. The keep-alive counter increments when the cycle produces no notifications; at MaxKeepAliveCount the server consumes one parked Publish request and returns an empty NotificationMessage. The lifetime counter resets whenever the server manages to send any response for that subscription. If the publish queue is empty, it cannot send, so the counter keeps climbing; at LifetimeCount the server deletes the subscription and reports StatusChangeNotification with Bad_Timeout.
OPC UA Part 4 requires the lifetime count to be at least three times the keep-alive count, so a server handed equal values must revise. With an assumed publishingInterval
Change the publishing interval — or let the server revise it to its own floor — and both derived times move with it. The important consequence: lifetime only arms when publish requests stop arriving. If keep-alive responses are still landing, the subscription is not timing out and the parameters are not your fault.
Which of the four fixes applies here?
| Approach | Fixes | Apply when | Cost |
|---|---|---|---|
Set Lifetime ≥ 3× MaxKeepAliveTime, log CurrentLifetime
|
Server deleting the subscription too eagerly |
StatusChanged reports Bad_Timeout or you get Bad_SubscriptionIdInvalid on the next Publish |
None, but it only widens the window; starvation still needs fixing |
| Keep the publish pipeline full — non-blocking callbacks, no exceptions escaping into the SDK dispatch path | The starvation itself | Gaps line up with long handler execution, UI marshalling, or logged callback exceptions | Work moves to a worker thread |
Reconnect handler plus TransferSubscriptions
|
Transport and session loss | Gaps line up with network events, VPN rekeys, or firewall idle timeouts | Must recreate subscription and items when the transfer is rejected |
Sample faster than you publish, or raise QueueSize
|
Individual values dropped while the subscription stays healthy | Keep-alives and most data arrive; only some values are missing; SamplingInterval equals PublishingInterval | Marginal server load and bandwidth |
Take the second one as primary and carry the first as margin. A client that loses updates intermittently while a reference client on the same endpoint runs clean is starving its own publish queue, and the queue drains from exactly one place: the thread that dispatches DataChanged. Fix the parameter relationship at the same time so a brief stall degrades into a late notification rather than a deleted subscription.
How do you re-tune and instrument the client?
- Handle
StatusChangedand log the status code with a timestamp.Bad_Timeoutmeans the server deleted the subscription after the lifetime elapsed;Bad_NoSubscriptionorBad_SubscriptionIdInvalidmeans you are publishing against an id that no longer exists. - Log every publish response with subscription id, sequence number, and whether it carried data or was a keep-alive. Contiguous sequence numbers with no payload prove the server is alive and has nothing to report.
- Set
MaxKeepAliveTimeto 5–20 publishing cycles andLifetimeto at least three times that, then readCurrentLifetimeafterCreate()and log it. Never trust the requested value. - Reduce
DataChangedto an enqueue. Any database write, file I/O, lock, or UI dispatch inside the callback holds the SDK's publish loop and drains the outstanding request pool. - Wrap the callback body in a try/catch that logs and swallows nothing silently. An exception thrown back into the dispatch path can stop the client resending Publish.
- Give the session timeout headroom over
CurrentLifetime, and register a reconnect handler that re-establishes the channel and transfers or rebuilds the subscription.
const double publishingInterval = 500; // ms
m_Subscription = new Subscription(session);
m_Subscription.PublishingEnabled = true;
m_Subscription.PublishingInterval = publishingInterval;
m_Subscription.MaxKeepAliveTime = 5000; // 10 publishing cycles
m_Subscription.Lifetime = 30000; // >= 3 x MaxKeepAliveTime
m_Subscription.DataChanged += SubscriptionOnDataChanged;
m_Subscription.NewEvents += SubscriptionOnNewEvents;
m_Subscription.StatusChanged += SubscriptionOnStatusChanged;
m_Subscription.Create();
// The server owns the final numbers - record what it granted.
Log("CurrentLifetime = " + m_Subscription.CurrentLifetime);
// Must return in well under one PublishingInterval.
private void SubscriptionOnDataChanged(/* SDK args */)
{
m_Inbox.Enqueue(/* copy of the notification */); // worker thread does the work
}
What breaks this again?
SamplingInterval equal to PublishingInterval with a monitored item queue of one is the classic false alarm. Two samples occasionally land inside one publishing cycle, the older one is discarded, and the client reports "missing updates" while the subscription is perfectly healthy. Sample at half the publishing interval or raise QueueSize to the ratio between the two.
Stateful firewalls and NAT gateways are the other repeat offender. A parked Publish request is a TCP connection with no traffic on it; if the idle timeout on any hop is shorter than MaxKeepAliveTime, the connection is torn down silently and the client discovers it only on the next write. Keep the keep-alive interval below the shortest idle timeout on the path.
Three more that mimic a lost subscription: PublishingEnabled left false, which produces keep-alives and no data; a deadband or filter on the monitored item that a default-configured reference client does not apply; and judging "no update" from a write that stored the same value, which generates no data change on servers reporting real changes. Check the source timestamp before blaming the transport.
How do you verify the subscription is really alive?
- Soak the client and watch publish sequence numbers. Any gap means a lost message, not a lost subscription — call
Republishfor the missing sequence number. If it returns the message, your client dropped it;Bad_MessageNotAvailablemeans the server never sent it. - Freeze the source data and time the keep-alives. They must arrive at
MaxKeepAliveTime± one publishing cycle. Longer means the server is not finding a Publish request to answer. - Read
Server.ServerDiagnostics.SubscriptionDiagnosticsArrayfor your subscription id:PublishRequestCountclimbing,UnacknowledgedMessageCountnear zero,DataChangeNotificationsCounttracking the real change rate, andMaxLifetimeCount/MaxKeepAliveCountmatching theCurrentLifetimeyou logged. - Capture on TCP 4840 against a None-security test endpoint and measure the gap between each PublishResponse and the next PublishRequest. It should be sub-millisecond. Tens of milliseconds means the callback thread is on the critical path; a growing gap means the pool is draining.
- Force the failure: block the worker thread or hold a debugger breakpoint longer than
CurrentLifetime, confirmStatusChangedfires withBad_Timeout, then release and confirm the reconnect handler rebuilds the subscription andDataChangedresumes on the next publishing cycle.
FAQ
Does the OPC UA server push data on its own, or does my client have to ask?
The server never pushes. Every notification rides in a PublishResponse that answers a Publish request the client parked on the session earlier, so the client keeps several requests outstanding at all times. If the pool empties, the server has nothing to answer and the data stops.
Can I set Lifetime and MaxKeepAliveTime to the same value?
You can request it, but the server will not honor it: the lifetime count must be at least three times the keep-alive count, so it revises upward. Read CurrentLifetime after Create() to see the value actually in force.
Does a blocking DataChanged handler kill the subscription?
Indirectly, yes. A handler that performs I/O or waits on a lock holds the SDK dispatch thread, the client stops resending Publish requests, and once the lifetime counter reaches its limit the server deletes the subscription with Bad_Timeout. Enqueue in the callback and process elsewhere.
Can I lose individual values while the subscription stays alive?
Yes. With SamplingInterval equal to PublishingInterval and a monitored item queue size of one, sampling jitter puts two samples in one publishing cycle and discardOldest throws one away. Sample at half the publishing interval or raise the queue size.