Knowledge Guide
HomeSystem DesignDistributed File System

Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)

Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)

These are seven senior-level “gotcha” questions that show up across system-design and backend interviews because each one hides a race, an asymmetry, or a false assumption behind an innocent-sounding scenario. They span topics — serialization, networking, streaming, concurrency, cost, caching, security — but they share a shape: something looks safe (“the schema just gained a field,” “the write got an ack,” “the idempotency key stopped duplicates”) until you trace the exact bytes or the exact interleaving and find the gap. Where relevant this page cross-references the guide’s existing DDoS Attacks page (SYN backlog economics), the Exactly-Once is a Myth page (the dedup-race pattern, reused here at the sink boundary), and the Distributed Cache Cluster page (replication lag and failover). The goal is the same every time: name the mechanism, not just the symptom.

1. Schema evolution: adding a field without breaking old clients

The mechanism that makes a schema change safe is always the same question: when an old reader meets new data, or a new reader meets old data, what happens to the field that only one side knows about? The three common formats answer that question completely differently, because each anchors its compatibility contract on a different thing — field names, field numbers, or a pair of schemas.

FormatWhat the contract is anchored onAdding a fieldRemoving / renaming a field
JSONNothing enforced by the transport — convention only. Readers that are written to ignore unrecognized keys and default missing ones are safe; readers that strictly validate are not.Safe if every consumer already ignores unknown keys (most hand-rolled parsers do; strict schema validators like some JSON Schema setups do not).Unsafe by default — nothing tells a consumer a key disappeared or was renamed; it silently reads as missing/null.
ProtobufField numbers (tags) in the .proto definition — not names. The wire format is a stream of (tag, wire-type, value) triples; names never travel on the wire.Safe: add a new field with a new, never-before-used tag number. Old readers that don’t recognize the tag skip it (Protobuf preserves/round-trips unrecognized fields rather than erroring).Unsafe to renumber or reuse a tag — a reused number silently reinterprets old bytes as the new field’s type. Removed fields must have their tag reserved so nobody reuses it later.
AvroA pair of schemas: the writer’s schema (embedded with the data or looked up from a schema registry) and the reader’s schema. Avro performs explicit schema resolution between the two at read time.Safe if the new field has a default value in the reader schema: a reader-with-new-field reading writer-without-field data fills in the default; a reader-without-new-field reading writer-with-field data just drops the extra value.Same rule in reverse — removing a field is safe only if the field being dropped had a default, so schema resolution can synthesize it for a reader that still expects it.

The one rule that generalizes across all three: never let “the same identifier” mean two different things over time — don’t reuse a Protobuf tag, don’t repurpose a JSON key for a different type, don’t drop an Avro default. That single discipline is what makes “just add a field” actually safe.

The compatibility modes (Confluent Schema Registry framing, but the concept generalizes)

A schema registry (Confluent-style, used with Avro or Protobuf) exists to enforce one of these modes automatically: it rejects a schema registration that would violate the configured compatibility mode, instead of discovering the break in production when an old consumer chokes on a replayed message.

2. The WebSocket reconnect gap: where did the 10 seconds of messages go?

A raw WebSocket is just a pipe — it has no memory of what passed through it before the current connection existed. If the pipe breaks and reopens, the new connection starts from nothing; whatever the server pushed while the pipe was down is gone unless something outside the socket remembers it. That something is a per-connection (or per-session) sequence number paired with a server-side buffer the client can replay from.

Traced: client drops for 10 seconds

tEvent
t=0Client connected on session S, last-seen sequence seq=104.
t=1–10sNetwork drops. Client is offline. Server keeps producing events for this client — seq=105..112 — and appends each to a durable per-session buffer (a bounded ring buffer or a short-retention log keyed by session id), not just to the dead socket.
t=10sClient’s heartbeat/pong times out → client detects the drop and starts reconnecting with backoff.
t=11sClient opens a new socket and, as its first message, sends a resume request: {session: S, last_seen_seq: 104} — not just “hello, give me new stuff.”
t=11sServer looks up session S’s buffer, finds everything with seq > 104 still retained (105..112), and replays it in order over the new socket before resuming live tailing.
t=11s+Client is now caught up exactly, and the new socket continues as the live tail from seq=113 onward.

The two moving parts are inseparable: the sequence number is what lets the client prove precisely what it last saw (so the server replays the exact gap, no more, no less — no duplicate replay, no silent gap), and the durable buffer is what lets the server answer “what happened between 104 and now” even though the socket that would have carried it is gone. If the client was offline longer than the buffer’s retention window, the server can’t answer precisely — the correct behavior is to tell the client explicitly (“gap exceeds retention, resync from source of truth”) rather than silently resuming and leaving an undetected hole. An equivalent pattern: instead of a ring buffer, give the client a durable inbox (e.g. a per-user outbox table or queue) it re-reads from its own last cursor on every reconnect — same idea, framed as pull instead of push-replay.

3. Stream-to-sink exactly-once: the sink has to cooperate

A stream processor can guarantee it will not lose or skip an input record — that’s just checkpointing the consumer offset after (or atomically with) processing, then replaying from the last checkpoint on restart. What it cannot unilaterally guarantee is that the corresponding output write to an external sink happens exactly once, because a crash between “wrote to the sink” and “committed the checkpoint” forces a replay that writes to the sink again. There are exactly two ways to close that gap, and both require the sink’s help:

Either way, the underlying insight is identical to the idempotency-key problem below — “exactly-once” is never really achieved by making delivery exactly-once (impossible, per the guide’s Exactly-Once is a Myth page); it’s achieved by making a duplicate delivery harmless at the point where it would otherwise cause damage. Here that point is the sink’s write path, not the consumer’s read path.

4. Idempotency-key concurrency: how the second worker is actually stopped

Job #8842 gets picked up twice — a queue visibility timeout expired while worker A was still mid-processing, so worker B also claimed it, or a duplicate enqueue happened upstream. Both workers reach the “send confirmation email” step at roughly the same time. An idempotency key only prevents the double-send if the check that “have I sent this already?” is atomic — a plain read-then-write check is a race, not a guard.

Traced: the naive race vs. the atomic fix

stepWorker AWorker B
1GET key:8842 → nil (not sent)
2GET key:8842 → nil (not sent)
3send email
4send email ← duplicate send
5SET key:8842SET key:8842

Both reads ran before either write — the classic read-modify-write hazard, the same shape as a lost counter++ update. The fix moves the “have I claimed this?” check into the storage layer itself, as a single atomic operation that only one caller can win:

-- Redis: only one caller's SETNX returns 1
SETNX key:8842 "claimed"   -- worker A: returns 1 (won the claim) -> proceed to send
SETNX key:8842 "claimed"   -- worker B: returns 0 (key already exists) -> skip, do not send

-- equivalently, at a SQL store:
INSERT INTO idempotency_keys(job_id) VALUES (8842)
  ON CONFLICT (job_id) DO NOTHING;  -- unique constraint decides the winner
-- rows_affected = 1 -> you won, send the email (same transaction)
-- rows_affected = 0 -> someone already claimed it, skip

The database (or Redis, atomically) serializes the two contenders on the key itself, so exactly one SETNX/INSERT can succeed — the decision is made by the storage engine, never by a prior read the caller performed. This is the identical CAS (compare-and-swap) discipline used at the sink boundary in §3.

Top: worker A and worker B both GET key:8842 and see nil, both send the email, then both SET the key -- a duplicate send. Bottom: both workers SETNX key:8842; worker A gets 1 and wins, sends once; worker B gets 0 and skips, returning the stored result.
Top: worker A and worker B both GET key:8842 and see nil, both send the email, then both SET the key -- a duplicate send. Bottom: both workers SETNX key:8842; worker A gets 1 and wins, sends once; worker B gets 0 and skips, returning the stored result.

5. Serverless cost reality: the bill is rarely dominated by compute

The pitch for serverless is “pay only for the compute you use,” so teams model cost as invocations × duration × memory and are then surprised when the actual AWS bill looks nothing like that estimate. The mechanism: a request almost never touches only the function’s compute meter — it also pays for everything that gets it to the function and everything the function reaches out for.

The mental model to carry into a cost estimate: serverless cost is dominated by the number of network hops a request causes — API Gateway’s per-request fee, NAT/data-transfer for anything crossing a VPC boundary, and one round trip per piece of externalized state — not by how many CPU-milliseconds the handler itself burns. A function that does 5ms of real work but makes three external calls to reconstitute its state will often cost more, per invocation, than a function that spins the CPU for 200ms with no external calls at all.

6. Session-loss on Redis failover: the acked write that never replicated

Sequence: the client writes a new session (say, right after login), Redis’s primary applies it and returns an ack, and then the primary dies before that write has propagated to any replica. A replica is promoted (Sentinel or Cluster failover) — but it never received that write, so the session simply does not exist on the new primary. The user, who was just told “logged in successfully,” is logged out on the very next request.

The mechanism is that Redis replication is asynchronous by default: the primary acknowledges a write to the client as soon as it applies the write locally, and streams the command to replicas afterward, off the critical path, for latency reasons. That ordering — ack-then-replicate rather than replicate-then-ack — is exactly what makes the write’s durability conditional on the primary surviving long enough to finish propagating it. (The guide’s Distributed Cache Cluster page covers the mirror-image symptom of the same asynchrony: a read routed to a replica right after a write to the primary can return the old value, because the replica hasn’t caught up yet.)

Mitigations — none of them make it free

7. DoS mechanics: three distinct classes, three distinct defenses

The guide’s DDoS Attacks page covers volumetric floods (SYN flood backlog exhaustion, UDP reflection/amplification) in depth. There is a third class worth naming explicitly because it exhausts neither bandwidth nor a fixed-size table by brute force: slow-connection exhaustion, plus the specific mechanics of the SYN-cookie defense and a fourth-ish variant, TLS-handshake CPU asymmetry.

SYN cookies: encoding state into the sequence number so there is no state to hold

A SYN flood works because the server allocates a backlog slot the instant it sees a SYN, before the handshake is proven genuine. SYN cookies remove the thing being exhausted: instead of allocating a slot and remembering “I sent a SYN-ACK to X, waiting for its ACK,” the server encodes everything it would have remembered directly into the 32-bit sequence number of the SYN-ACK itself — a keyed hash (MAC) of the connection’s 4-tuple (source/dest IP and port), a secret known only to the server, a coarse time window (so cookies expire), and a few bits for the negotiated MSS. It sends that crafted sequence number and then forgets the connection attempt entirely — zero backlog entry, zero memory held.

When the real client answers with its final ACK, the ack number it must send back is exactly ISN+1 — which only a client that actually received the SYN-ACK (i.e. actually owns the claimed source IP) can produce. The server, on receiving that ACK, recomputes the same hash from the packet’s own 4-tuple and the current time window and checks it matches the returned value; only at that point, having verified the round trip really happened, does it allocate the real connection state (recovering the negotiated MSS from the encoded bits). A spoofed SYN flood never produces this ACK — the attacker doesn’t control the spoofed IP’s inbox, so it never sees the SYN-ACK to answer — meaning the server verifies nothing and stores nothing for every spoofed packet, no matter how many arrive. The trade-off: because only a limited number of bits are available in a sequence number, SYN cookies can’t faithfully encode every TCP option (large window scaling in particular can get dropped or reduced while cookies are active), which is the same limitation noted on the DDoS page.

Client sends SYN; server computes a hash-based cookie as the SYN-ACK sequence number and stores no state; real client ACKs with ack=ISN+1, server re-derives the hash to verify and only then allocates the connection. A spoofed SYN never receives its SYN-ACK, so it never completes the ACK and the server never stores anything for it.
Client sends SYN; server computes a hash-based cookie as the SYN-ACK sequence number and stores no state; real client ACKs with ack=ISN+1, server re-derives the hash to verify and only then allocates the connection. A spoofed SYN never receives its SYN-ACK, so it never completes the ACK and the server never stores anything for it.

Slowloris: exhausting the connection pool with almost no bandwidth

Slowloris is a third DoS class, distinct from both volumetric (saturate the pipe) and classic application-layer (flood with complete, valid-looking requests) attacks: it exhausts the server’s concurrent-connection pool using barely any bandwidth at all. The attacker opens many HTTP connections and sends a valid but incomplete request — a partial header, or headers with no terminating blank line — then trickles a few more bytes every so often, just often enough to stay under the server’s read/idle timeout, so the connection is never considered dead and never completes either. Each such half-open request occupies one worker thread, process, or connection slot for as long as the attacker keeps trickling bytes. A thread-per-connection server with a modest worker pool (historically the failure mode Apache’s prefork MPM hit) can be fully saturated by a few hundred to a few thousand such slow connections — trivial for a single attacking machine, no botnet or amplification required, because the attack is priced in held connections, not packets per second.

TLS-handshake CPU asymmetry

A TLS handshake requires the server to perform genuinely expensive asymmetric-crypto operations (key exchange, certificate signing/verification) for every incoming attempt, while triggering one costs the client comparatively little. A flood of handshake initiations (or repeated renegotiation requests on already-open connections) forces the server to burn real CPU cycles on cryptographic math per attempt — exhausting compute rather than memory (SYN flood) or connection slots (Slowloris). This is why TLS termination at scale is commonly offloaded to dedicated hardware or a CDN/load balancer designed to absorb exactly this asymmetry.

Mitigations, matched to the mechanism

Pitfalls

The judgment layer

Protobuf vs Avro vs JSON, when to pick which. Reach for Protobuf when you control both ends of an RPC boundary and want compact, fast, statically-typed wire messages with strong compile-time contracts (gRPC services). Reach for Avro when you’re writing to a shared, long-lived log (Kafka topics with many independent consumer teams) where a central schema registry enforcing compatibility modes matters more than per-message size, and where the writer/reader schema-resolution model handles evolution across many historical versions gracefully. Reach for JSON when human-readability and tooling ubiquity (browsers, curl, ad-hoc debugging) outweigh compactness and you’re willing to enforce compatibility by convention and code review rather than by the format itself.

2PC-style atomic sink commit vs idempotent offset-keyed sink. Prefer the atomic co-commit only when the sink is capable of participating in one transaction with the stream’s own checkpoint store (same-system cases like Kafka-to-Kafka) — it’s strictly stronger but narrowly available. For everything else (an arbitrary external API, a database without cross-system transaction support), the idempotent/offset-keyed sink is the practical default: it costs a bit of extra sink-side bookkeeping (a dedup table or an upsert key) but works with any sink that can do a conditional write.

Redis WAIT vs a durable store vs tolerating re-login. WAIT is the right dial to reach for first because it’s a one-line change with no architectural cost — use it when the data is important but a total redesign is not justified. Move to a genuinely durable store when the data (payment state, order state) truly cannot be allowed to vanish, accepting the latency Redis was there to avoid. Tolerating occasional re-login is the right call only after you’ve actually measured how rare primary failover is in your environment — treating a once-a-year event as demanding a full durable-store migration is over-engineering; treating a frequent one that way is under-engineering.

Takeaways

Related pages

🤖 Don't fully get this? Learn it with Claude

Stuck on Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)** (System Design) and want to truly understand it. Explain Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Distributed & Network Gotchas — Schema Evolution, WebSocket Reconnect, Exactly-Once Sink, Serverless Cost, Session-Loss & DoS Mechanics (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes