Drain & Shutdown
The last page made the connection's state machine observable — every transition wired to a callback, the current state readable for a health check. This page drives one of those transitions on purpose: the clean exit, when your own process goes away. When a deploy rolls out, a pod is rescheduled, or an operator sends SIGTERM, your client has a few seconds to disconnect cleanly.
The naive shutdown discards in-flight work. The warehouse subscriber
has messages sitting in its buffer that its handler hasn't run yet.
order-svc has a publish that hasn't reached the server. If the process
just exits, all of that is gone. This page replaces the naive shutdown
with one that finishes its in-flight work first.
Two concepts do that: the difference between close and drain, and the drain timeout that bounds how long drain is allowed to take. After those, the page narrows the same idea twice: a drain scoped to one subscription, and flush, the server round trip that drain uses internally.
Close drops in-flight work
A close is an abrupt teardown: the client shuts the TCP socket right
away, sends no UNSUB, and doesn't wait for in-flight work. In the client
libraries this is the Close() call. It flushes whatever is already in the
write buffer on its way out, but it stops delivering buffered inbound
messages to your handlers and drops the reconnect buffer. Killing the
process with SIGKILL is harsher still: it loses the write buffer too.
Two kinds of work are in flight at shutdown, and close treats them differently.
An in-flight message is one the server has already delivered to the
subscriber but the handler hasn't finished; it's sitting in the
subscription's buffer waiting its turn. When warehouse closes, every
buffered orders.created event that hasn't run yet is dropped, and the
handler never sees it.
A pending publish is the other kind: order-svc called publish, but the
bytes are still in the client's write buffer, not yet on the wire. A
library Close() flushes that buffer as it tears down, so a clean close
usually still gets the order to the server; an abrupt exit — a crash or a
SIGKILL — loses it, because the write buffer dies with the process.
Close is the right call only when you're tearing down a connection you no longer care about, such as a failed health check, a test, or an error path where the work is already lost. For a planned shutdown, use drain instead.
Drain finishes in-flight work, then closes
A drain is a graceful shutdown that flushes before it closes. Instead
of dropping in-flight work, the client winds the connection down in order
so nothing buffered is lost. In most client libraries this is the
Drain() call; the C# client instead drains on dispose when you set
DrainSubscriptionsOnDispose.
Drain runs in two phases. In nats.go and nats.py the connection reports each phase as a state — DRAINING_SUBS, then DRAINING_PUBS — while the other clients keep the phases internal. First the client sends an UNSUB for every subscription so the server stops delivering new messages, then it lets the handlers finish every in-flight message already in the buffers. Once the subscriptions are quiet, it flushes every pending publish to the server. Only then does it close.
The order matters. Drain stops new work from arriving, lets existing work complete, then pushes out anything queued before it closes last, whereas a close goes straight to the close without those steps.
The animation shows these side by side. Close shuts the connection while messages are still buffered; drain delivers those last messages to the handlers, flushes the pending publish, and only then closes.
Drain on a shutdown signal
The place to call drain is your process's shutdown handler. When the
runtime receives SIGTERM, instead of letting the process exit, you start
the drain and wait for the connection to report CLOSED before the process
exits. The warehouse subscriber drains its buffered orders; order-svc
flushes its pending publish. Both exit having handled everything they had.
How you wait for completion depends on the client. In JavaScript and Python
await nc.drain() resolves only once the drain is done. In Go Drain()
returns immediately and drains in the background, so you register a closed
handler (nats.ClosedHandler) and wait on it — returning from Drain() is
not the signal that draining finished, and a process that exits on that
return loses the very work drain was meant to save.
The nats CLI shows the close case on SIGINT: press Ctrl-C against a
running nats sub and the process just exits, abandoning in-flight
messages. nats reply, by contrast, installs an interrupt handler that
calls Drain() on Ctrl-C — the right place for the call. It exits as soon
as Drain() returns, though, without waiting for the background drain to
complete, so it shows where the drain belongs rather than a finished drain.
That's the Go pitfall from the previous paragraph in practice: exiting on
Drain()'s return abandons the work drain was meant to save. In client
code the full pattern is Drain() wired to a SIGTERM handler that waits
for CLOSED before the process exits.
- CLI
- C
#!/bin/bash
# Where the Drain() call belongs on shutdown, shown with `nats reply`.
#
# `nats reply` installs an interrupt handler that calls Drain() when you
# press Ctrl-C -- the right place for the call. It exits as soon as Drain()
# returns, though, without waiting for the drain to complete (nats.go drains
# in the background), so use it to see where the drain call belongs rather
# than to observe a finished drain. (`nats sub` has no such handler: Ctrl-C
# just terminates the process and abandons any in-flight messages.)
#
# In a client library the full pattern is Drain() wired to a
# SIGTERM handler that waits for CLOSED before the process exits.
nats reply "orders.lookup" \
'order ord_8w2k: shipped' \
--server nats://n1:4222 \
--connection-name warehouse
// SIGTERM arrived: drain instead of close. The connection stops
// new deliveries, lets in-flight handlers finish, flushes
// pending publishes, then closes.
s = natsConnection_Drain(conn);
// Drain returns immediately and runs in the background, so wait
// for CLOSED before the process exits -- exiting on Drain()'s
// return abandons the work drain was meant to save.
while ((s == NATS_OK) && !natsConnection_IsClosed(conn))
nats_Sleep(100);
printf("drain complete, exiting\n");
The message moving through the drain is the same order event as every other page:
{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}
The two phases from above also decide what a late publish does. While the
connection is draining its subscriptions (DRAINING_SUBS), most clients still
accept a publish — deliberately, so a handler can send its reply as it
finishes. Once the connection moves on to flushing publishes (DRAINING_PUBS)
it refuses new ones, and a publish then returns a draining error
(ErrConnectionDraining in nats.go, the equivalent in each library). So a
publish issued right after Drain() races the phase change: it may slip
through or it may be rejected. That race, not a guaranteed error, is why
drain is the last thing your shutdown does, after the application has
stopped producing.
The drain timeout bounds how long drain waits
Drain waits for handlers to finish, so a handler that hangs would hang
the shutdown forever. The drain timeout prevents that. In nats.go and
nats.py it's the deadline for the subscription phase: if the in-flight
handlers don't finish within it, drain stops waiting, discards the messages
that are left, and moves on to the final flush and close anyway. (The
pending-publish flush has its own short bound — five seconds, hardcoded, in
nats.go. In nats.java the timeout you pass to drain() covers the final
flush too.) The client surfaces a drain-timeout error (ErrDrainTimeout in
nats.go) so you know the shutdown was cut short rather than completed.
The default is generous (30 seconds in nats.go), but the right value
depends on your slowest handler. If a warehouse handler can take five
seconds to write an order to a database, a one-second drain timeout will
cut it off mid-write every deploy. Size the timeout to your handler
latency rather than to a round number.
- CLI
- C
#!/bin/bash
# Drain has a deadline. If in-flight handlers do not finish within the
# drain timeout (default 30s in nats.go), the remaining queued messages
# are discarded and the connection closes anyway, surfacing a
# drain-timeout error.
#
# The CLI does not expose a separate drain-timeout flag -- its global
# --timeout bounds how long a single operation waits. The point this
# snippet stands in for lives in client code: size the drain timeout to your
# slowest handler so a clean shutdown does not silently drop work.
#
# This publishes the canonical order event with a generous operation
# timeout so a slow handshake is not cut off mid-flush.
nats pub orders.created \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
--server nats://n1:4222 \
--connection-name order-svc \
--timeout 30s
// Size the drain timeout to the slowest handler, with margin: a
// two-second handler gets ten seconds, not a round one second. If
// the timeout fires, the messages left are discarded and the drain
// surfaces NATS_TIMEOUT instead of completing. The default, with
// natsConnection_Drain(), is 30 seconds.
if (s == NATS_OK)
s = natsConnection_DrainTimeout(conn, 10000);
// Wait for CLOSED: only then has the drain finished (or been cut
// short by the timeout).
while ((s == NATS_OK) && !natsConnection_IsClosed(conn))
nats_Sleep(100);
if (s == NATS_OK)
printf("drained within the timeout, nothing dropped\n");
Here we cover only the options that change how a connection behaves under fault; the exact option names and defaults live in your client's API reference, while Reference covers the wire protocol and server configuration.
Draining a single subscription
Drain() on the connection winds down everything the connection holds:
every subscription, then the pending publishes, then the socket. Often the
shutdown is smaller than that — one subscriber rotates out for a deploy
while the process and its other subscriptions keep working. For that, most
clients put drain on the subscription itself: it unsubscribes, so the
server stops sending, lets the handler finish the messages already
buffered for that one subscription, and leaves the connection and every
other subscription open.
The warehouse subscribers run as a queue group (Core NATS →
Queue groups covers the mechanism):
several members share the orders.> load. Rotating one member out is the canonical use. Drain that
member's subscription and it finishes the orders it holds while the server
keeps delivering new orders to the remaining members; nothing is dropped
and the group never stops consuming.
The call is Subscription.Drain() in Go and sub.drain() in JavaScript
and Python. Java puts drain(Duration) on both a Subscription and a
Dispatcher, returning a future that completes when the drain does. In
Rust, Subscriber::drain() unsubscribes immediately and the subscriber's
stream ends once the remaining messages are delivered.
C# is the exception: it has no drain call on a single subscription.
Disposing a subscription unsubscribes and completes its message channel at
once, so messages still on their way from the server are dropped. The
drain it does offer is the connection-level opt-in from earlier on this
page, DrainSubscriptionsOnDispose. It defaults to false; when set,
disposing the connection first drains each remaining subscription — an
UNSUB, then a PING/PONG round trip (bounded by DrainPingTimeout, five
seconds by default) that lets the last messages arrive before the channel
completes. Leave it off and a dispose silently drops whatever the handler
hadn't reached yet, so set it on any C# service whose subscriptions do
real work.
The CLI can't demonstrate a per-subscription drain: nats sub holds
exactly one subscription, and Ctrl-C closes it, as this page covered. The
CLI example runs one warehouse queue-group member — the subscriber a
client would then rotate out with a subscription drain:
- CLI
- C
#!/bin/bash
# Rotating one queue-group member out without dropping its in-flight
# orders is a per-subscription drain: unsubscribe, finish the messages
# already buffered for that subscription, and leave the connection and
# every other subscription alive.
#
# The CLI cannot express it -- `nats sub` holds exactly one subscription
# and Ctrl-C closes rather than drains. This runs one warehouse member
# of the queue group; the drain call that rotates it out is a
# client-library call.
nats sub "orders.>" \
--queue warehouse \
--server nats://n1:4222 \
--connection-name warehouse-2
// One member of the warehouse queue group, sharing the orders.>
// load with the other members.
if (s == NATS_OK)
s = natsConnection_QueueSubscribe(&sub, conn, "orders.>",
"warehouse", onMsg, NULL);
// Rotate this member out without dropping its in-flight orders:
// drain the subscription, not the connection. It unsubscribes, so
// the server routes new orders to the remaining members, and the
// handler finishes what is already buffered here.
if (s == NATS_OK)
s = natsSubscription_Drain(sub);
// The drain runs in the background; block until it completes (zero
// means no timeout), then check how it ended.
if (s == NATS_OK)
s = natsSubscription_WaitForDrainCompletion(sub, 0);
if (s == NATS_OK)
s = natsSubscription_DrainCompletionStatus(sub);
// The connection and every other subscription are still alive.
if (s == NATS_OK)
printf("member rotated out, connection still %s\n",
natsConnection_IsClosed(conn) ? "closed" : "open");
Flush
Drain's last phase pushes every pending publish to the server before the
connection closes. You can ask for that barrier on its own, with nothing
shutting down. A flush sends a PING to the server and waits for the
PONG. The server processes what a connection sends in order, so when the
PONG comes back, the server has also received everything the client wrote
before the PING — including every buffered publish. For a fire-and-forget
publish, that round trip is the only confirmation there is: after a flush
returns, the orders order-svc published are on the server.
Flush confirms receipt by the server and stops there: it doesn't tell you any subscriber saw the message, and it isn't an acknowledgment that a message was stored — storage confirmations are JetStream publish acks (JetStream → Publishing).
In Go the call is Flush(), with a fixed ten-second timeout, or
FlushTimeout() with a bound you pick. Python's nc.flush(), Java's
flush(Duration), and JavaScript's await nc.flush() do the same round
trip. C# has no flush call; PingAsync() is the same PING/PONG and
returns the elapsed time. The Rust client differs in substance: its
client.flush() resolves once the buffered writes have reached the
socket, without waiting for the server's PONG, so it confirms the bytes
left the client rather than that the server received them.
Drain already ends with this flush, so a drained shutdown doesn't need one
of its own. Call flush at the checkpoints drain doesn't cover: after
order-svc publishes a burst of orders and before the code treats the
batch as sent, or before a close on an error path where you still want the
buffered writes to reach the server.
The same round trip, timed, is the client's latency probe. Go has RTT(),
Python rtt(), Java RTT(), and JavaScript rtt(); C#'s PingAsync()
returns the duration directly, and Rust's async client exposes neither the
probe nor a round-trip flush. The nats CLI has the probe as nats rtt:
for each server it can reach, it runs five round trips and prints the
averaged time per address. Flush itself is a library call with no CLI
equivalent, so the CLI example shows nats rtt against the pool; the
flush itself is the client-library call:
- CLI
- C
#!/bin/bash
# Flush is a client-library call: a PING the server answers with a PONG,
# confirming the server received everything the client wrote before it.
# The CLI has no flush command; the closest observable is `nats rtt`,
# the same PING/PONG round trip reported as a duration.
#
# For each server it can reach, `nats rtt` runs five round trips and
# prints the averaged time per resolved address.
nats rtt --server nats://n1:4222,nats://n2:4222,nats://n3:4222
// A burst of fire-and-forget publishes: the orders may still sit in
// the client's write buffer.
for (i = 0; (s == NATS_OK) && (i < 3); i++)
s = natsConnection_PublishString(conn, "orders.created",
"{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
"\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}");
// Flush sends a PING and waits for the PONG. The server processes a
// connection's traffic in order, so when this returns the server
// has received every publish above. natsConnection_Flush() is the
// same call with a default 10-second bound.
if (s == NATS_OK)
s = natsConnection_FlushTimeout(conn, 5000);
if (s == NATS_OK)
printf("flushed: the server has the batch\n");
When the server drains first
Drain is usually something your client initiates. But the server can ask
for it. A server entering a graceful shutdown signals lame duck: its
INFO message carries a flag (ldm) telling connected clients it's about
to go away. A client that watches for it can stop publishing and
reconnect to another server in the pool before the link is cut, rather than
waiting to be disconnected.
That INFO also updates the server list a reconnecting client picks from:
the departing server sends connect_urls holding only the other
servers, with its own address removed, so a reconnecting client won't
pick the server that's leaving. The advertised list, and the
no_advertise switch that empties it, are covered earlier in this chapter
under Discovered
servers.
Detecting lame duck is a callback your client sets, one of the connection events from the last page. Why a server enters lame duck (a rolling upgrade, a node drain) is a server-side decision covered in Topologies. Here it's just a hint the client may act on.
Pitfalls
Three mistakes turn a clean shutdown back into a lossy one.
Publishing after you call drain. Drain can't be reversed. Code that
calls Drain() and then tries to emit a final "shutting down" event is
racing the drain: depending on which phase the connection has reached, the
publish either slips through unnoticed or comes back with a draining error
(ErrConnectionDraining in nats.go) and the event is lost. You can't count
on either outcome. Drain last, after the application has stopped producing
work, so no publish ever has to win that race.
Handle the draining error instead of letting it look like success:
- CLI
- C
#!/bin/bash
# Publishing after drain is a race, not a clean rejection. Drain runs in
# two phases: while the client is draining its subscriptions it still
# accepts a publish (so a handler can reply), and only once it moves on to
# flushing publishes does a new publish come back with a draining error
# (ErrConnectionDraining in nats.go, the equivalent in each library). A
# publish issued right after Drain() may slip through or may be rejected.
#
# The nats CLI has no scriptable "publish after drain" path: once it
# unsubscribes on Ctrl-C it exits, so there is no later publish to reject.
# The CLI cannot race a drain, so this snippet only carries the message
# shape. The teaching point is for client code: handle the draining
# error from a publish issued after Drain() instead of assuming the call
# succeeded -- drain last, after the application has stopped producing. Do
# not interleave a final "shutting down" publish with the shutdown.
nats pub orders.created \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
--server nats://n1:4222 \
--connection-name order-svc
const char *order = "{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
"\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}";
// Drain cannot be reversed. Once it starts, a publish is racing the
// drain's phases: it may slip through while subscriptions drain, or
// come back with NATS_DRAINING once the connection is flushing
// publishes.
s = natsConnection_Drain(conn);
if (s != NATS_OK)
fprintf(stderr, "drain failed: %s\n", natsStatus_GetText(s));
// A "shutting down" event published after Drain() cannot count on
// either outcome. Handle the draining error instead of assuming the
// publish succeeded -- and in real code, drain last, after the
// application has stopped producing.
s = natsConnection_PublishString(conn, "orders.created", order);
if ((s == NATS_DRAINING) || (s == NATS_CONNECTION_CLOSED))
fprintf(stderr, "publish rejected, connection is draining: %s\n",
natsStatus_GetText(s));
else if (s == NATS_OK)
printf("publish slipped through before the drain reached publishes\n");
A drain timeout shorter than your slowest handler. When the timeout fires, in-flight work is discarded rather than finished. Set it below the time a handler actually needs and every deploy silently drops the orders that were mid-handle. Measure your slowest handler and set the drain timeout above it, with margin. Pick a number that covers the work rather than a round one.
Assuming a core drain acks your JetStream messages. Drain winds down
the connection: it unsubscribes, finishes in-flight messages, and
flushes publishes. It does not acknowledge a JetStream consumer's
messages for you. An in-flight message from the analytics consumer that
the handler finishes during drain still needs its acknowledgment sent
before the connection closes, or JetStream will redeliver it to the next
client. Acknowledge JetStream work explicitly; how a consumer's position
moves is JetStream → Acknowledgment,
not the connection's job.
Where you are
order-svc and the warehouse, notifications, and analytics
subscribers now exit cleanly. A SIGTERM triggers a drain, not a close:
buffered orders are handled, pending publishes are flushed, and the drain
timeout keeps a stuck handler from hanging the shutdown forever. A single
warehouse queue-group member can rotate out with a subscription drain
while the rest of the group keeps consuming, and a flush confirms the
server received order-svc's publishes without shutting anything down.
The connection that survives a server going away now also survives its own
process going away.
What's next
A connection can be lost from the outside (a server dying), and now it can be torn down cleanly from the inside. The next failure is internal and quieter: a subscriber whose handler can't keep up, so its buffer fills faster than it drains. That's the slow consumer, and left unbounded it grows until the process is killed.
Continue to Slow Consumers.
See also
- JetStream → Acknowledgment — what happens to a consumer's position for messages in flight at drain.
- JetStream → Publishing — the publish ack, the storage confirmation that flush is not.
- Core NATS → Queue groups — the queue group whose member a subscription drain rotates out.
- Topologies → Your first cluster — why a server enters lame duck and signals its clients to leave.
- Reference — the wire protocol and server configuration; the drain timeout and every other connection option, with its default, live in your client's API reference.