Distributed ID generation — UUID, ObjectId, Snowflake, clock skew
IDs as a distributed-systems decision, and the two choices you actually made.
Concept → your projects
ID strategy trades off four properties: uniqueness without coordination, sortability (roughly time-ordered → keyset pagination, B-tree locality), size (index bloat: UUIDv4 = 16 random bytes → terrible index locality, page splits everywhere), and opaqueness (sequential IDs leak business volume — German tank problem).
The menu:
- Autoincrement/sequence (traverse): sortable, tiny, but requires the single database — dies with sharding, and leaks counts.
- UUIDv4 (zoth): random 122 bits — coordination-free, opaque; the cost: random inserts scatter across the B-tree (write amplification, cold cache locality).
- UUIDv7 (the modern fix): time-prefixed UUID — coordination-free AND roughly sorted. If zoth were greenfield today, this is the answer to recite.
- Mongo ObjectId: 12 bytes = timestamp + machine + counter — sortable, decentralized.
- Snowflake: 64 bits = timestamp(41) + machine(10) + sequence(12) — sortable, coordination-free, requires clock discipline (the interesting part).
Clock skew is the Snowflake lesson: IDs must be monotonic per generator, but NTP steps backward → a node emitting smaller timestamps than before → duplicate sequence-space collisions → Snowflake implementations refuse to issue IDs until the clock catches up (or borrow time). This generalizes: any time-ordered system needs bounded clock skew handling — your NTP answer: chrony/ntpd keeps skew ~ms; critical monotonicity needs logical clocks or wait-on-regression (the Lamport idea; version vectors for causality — deeper treatment in the consistency days).
Your two choices, defended:
- Traverse sequences: one database, needs row IDs for joins and keyset pagination; leak of submission counts is acceptable (user-facing product, counts aren't secret). Simplest correct answer.
- Zoth UUIDs: IDs appear in URLs, events, and cross-service logs (request_id flows api→outbox→emitter); coordination-free generation matters because events are constructed before persistence; opacity matters (no volume leak). The locality cost is real but bounded — tables are small, and UUIDs are v4 because that's
google/uuid's default; v7 would be the upgrade (index locality + still opaque). Say that sentence.
Keyset pagination (the companion topic): WHERE id < $cursor ORDER BY id DESC LIMIT n needs monotonic IDs — works with sequences and with time-ordered UUIDs (v7/Snowflake), not with v4 (random order = no cursor meaning). Traverse's inbox paginates on sequential ids; zoth lists use offset-optional SQL — fine at its scale, and the honest note is "keyset needs sortable keys; my UUIDs aren't, which is one more reason v7."
Bloom filters (the sibling topic): "is X a member?" with ~1% false-positive at ~10 bits/element — membership at 1% the memory of a set. Your systems use exact dedup (unique constraints, processed_events) because correctness is cheap at your scale; Bloom's home is the scale where exact sets don't fit: "has this URL been crawled?", "might this key exist in cache?" (avoid disk hits), follower-seen filtering on feeds (Twitter's classic use). The rule: Bloom trades false positives for memory — only acceptable when a false positive is cheap (a wasted lookup), never when it's a wrong decision (granting access — zoth dedup must be exact, and is).
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
Why did you choose UUIDs for zoth and sequences for traverse?
Zoth IDs cross service boundaries (events, logs, URLs) and are generated before persistence, so coordination-free + opaque UUIDs fit; the cost is B-tree locality, which is bounded at my table sizes — and I'd move to UUIDv7 for time-ordered locality today. Traverse is a single-database product where sequential ids give keyset pagination and tiny indexes; the volume leak is fine for a consumer product. The general rule: sequences need one database; UUIDs need none; v7/Snowflake give you both coordination-freedom and sortability.
What goes wrong with random UUIDs in a B-tree?
Inserts land at random leaf positions — every insert is a random page in the index's working set: poor buffer locality, more page splits, cold-cache random I/O at scale. Time-ordered keys (sequences, v7, Snowflake) append at the right edge — hot pages stay cached, splits amortize. At zoth's scale this is unmeasurable; at tens of millions of rows it's a real write-amplification difference — the reason v7 exists.
When is a Bloom filter the right dedup tool?
When the set is too big for memory and a false positive is cheap — 'have I crawled this URL' (wasted fetch), 'might this be cached' (wasted lookup), feed-seen filtering (rarely-show an already-seen post). My processed_events dedup is exact and insert-on-conflict because a false 'seen' would drop a real access-revocation — in security paths the false positive must be impossible. Bloom is an optimization tier for cheap-to-be-wrong membership, and that's never an authorization decision.