Skip to content

Why Did I Choose Snowflake ID for Messages?

Published: July 01, 2026

Background — An ID Is Not Just an Identifier

Section titled “Background — An ID Is Not Just an Identifier”

Every message in Minichat is assigned a unique ID when it is created. This ID is not just a value to distinguish one message from another.

Looking at where it is used:

  • Sort order for message queries
  • lastReadMessageId — tracking how far a user has read
  • lastWrittenMessageId — determining the latest message in a chatroom
  • Ordering basis for Kafka Consumer event processing
  • Comparison criterion for conditional DB UPDATEs (WHERE ... < ?)

The ID serves as the sort key, the freshness indicator, and the concurrency control reference. How it is generated determines the design options available to the rest of the system.

The Problem — Generating IDs in a Distributed Environment

Section titled “The Problem — Generating IDs in a Distributed Environment”

On a single server, ID generation is straightforward. The database assigns sequential numbers on each INSERT.

But when multiple servers are involved, the problem changes. Each server creates messages simultaneously and must assign IDs independently. A centralized numbering scheme can become a bottleneck, and a collision-free random value loses ordering information.

I started by reviewing the most familiar approaches.

Auto-Increment delegates ID generation to the database — a well-proven pattern. Sequential ordering and uniqueness are guaranteed by the DB, and implementation is simple. But in an environment where servers scale horizontally, the question shifts:

“Can a single database remain the central point for all ID generation?”

In a distributed environment, each server needs to generate IDs independently. But then, is it enough to simply generate collision-free IDs?

Analyzing the Data — What Information Should an ID Carry?

Section titled “Analyzing the Data — What Information Should an ID Carry?”

The real question was not “how to create a unique ID” but “what meaning should this ID carry.”

I defined the requirements for Minichat’s message ID.

1. Does it need to preserve time ordering? lastReadMessageId and lastWrittenMessageId determine freshness by comparing ID values. If the ID does not reflect time ordering, these comparisons break down.

2. Must it be generated without a central database? In real-time chat, multiple servers generate messages concurrently. Routing every ID generation through a single database introduces latency on the generation path.

3. Must it support numeric comparison? It is used directly in conditional UPDATEs like WHERE last_written_message_id < ? and Lua Script comparisons like if ARGV[1] > current.

In summary, what I needed was not “a collision-free value” but “a value that can be generated in a distributed environment, preserves time ordering, and serves as a basis for freshness comparison.”

CriterionAuto-IncrementUUIDSnowflake
Generation locationDatabaseApplicationApplication
Time orderingGuaranteedNoneTime-based increase
Distributed generationDifficultPossiblePossible
DB dependencyHighNoneNone
Numeric comparisonPossibleNot possiblePossible
Implementation complexityLowLowHigh

Auto-Increment is simple but carries DB dependency. UUID enables distributed generation but loses ordering information. Snowflake accepts implementation complexity in exchange for both distributed generation and time-based ordering.

In the current project, the ID is not just an identifier — it is used for data comparison and consistency checks. That made Snowflake a suitable choice for this data model. But not every system requires Snowflake.

Snowflake ID structure: [1-bit sign] [41-bit timestamp] [10-bit nodeId] [12-bit sequence]

Because the timestamp occupies the most significant bits, the value naturally increases as time passes. Within the same millisecond, the sequence increments. Different servers are distinguished by nodeId.

However, if clock drift causes the server clock to move backward, monotonic increase breaks. If this assumption fails, every comparison operation that depends on this ID becomes invalid.

What mattered in the implementation was not the Snowflake algorithm itself, but ensuring that the premise established earlier — IDs preserve time ordering — holds at the implementation level as well.

public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis() - customEpoch;
// A backwards clock breaks the monotonic increase premise, so refuse to generate
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id.");
}
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
// If 4096 IDs are exhausted within the same millisecond, wait for the next one
currentTimestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = currentTimestamp;
return currentTimestamp << (NODE_ID_BITS + SEQUENCE_BITS)
| (nodeId << SEQUENCE_BITS)
| sequence;
}

Setting a custom epoch of 2025-01-01 gives the 41-bit timestamp a lifespan of roughly 69 years.

Clock drift was initially handled by waiting, but I switched to refusing generation, judging that failing fast is operationally sounder than an unbounded wait.

Test 1: Same nodeId, Multi-threaded Concurrent Generation

Section titled “Test 1: Same nodeId, Multi-threaded Concurrent Generation”

10 threads simultaneously generated 1,000 IDs each from a single Snowflake instance — 10,000 total.

MetricValue
Total generated10,000
Unique IDs10,000
Duplicates0 ✅

synchronized prevents sequence collisions within the same millisecond.

Test 2: Different nodeIds, Concurrent Generation

Section titled “Test 2: Different nodeIds, Concurrent Generation”

nodeId 1 and nodeId 2 each generated 5,000 IDs simultaneously — 10,000 total.

MetricValue
Total generated10,000
Unique IDs10,000
Collisions0 ✅

Even when generated in the same millisecond, different nodeIds produce different bit compositions, eliminating collisions. Node 1: timestamp=22:43:22.057, nodeId=1, seq=0

Node 2: timestamp=22:43:22.057, nodeId=2, seq=2852 → Same timestamp, different IDs

10,000 IDs generated sequentially. Every ID was confirmed to be greater than its predecessor.

MetricResult
Monotonic increase✅ All 10,000 IDs greater than previous
ID Timestamp NodeId Seq

197708594001481728 2026-06-30 22:43:22.058 1 0

197708594001481729 2026-06-30 22:43:22.058 1 1

197708594001481730 2026-06-30 22:43:22.058 1 2

197708594009872141 2026-06-30 22:43:22.060 1 1805

197708594009872142 2026-06-30 22:43:22.060 1 1806

197708594009872143 2026-06-30 22:43:22.060 1 1807

Within the same millisecond, the sequence increments. When the millisecond changes, the sequence resets. Comparing ID values is equivalent to comparing time order.

The design decisions covered in this series all depend on a single property that Snowflake ID provides:

Message IDs increase as time passes.

This property shaped how consistency and ordering were handled across the system.

PartDependent assumptionDecision it enabled
Part 1IDs increase monotonically → max comparison possibleLua Script instead of distributed lock
Part 2IDs increase monotonically → ordering reversal absorbedKafka Key removed for user-chat-update
Part 3Different consistency requirements per readReplica Routing
Part 4Permanent bans stored in DB → fallback possible on Redis failureFail-Partial strategy

In Part 1, I wrote “Snowflake ID increases monotonically, so this is a max problem.” In Part 2, I wrote “conditional UPDATE absorbs ordering reversal.” The starting point for both was the ID generation strategy.

If UUID had been chosen, time ordering could not be determined from the ID alone — separate sort criteria or version management would have been necessary. If Auto-Increment had been chosen, the database would have become a bottleneck in a distributed environment.

The initial ID design opened up the options for concurrency control, ordering guarantees, and failure response strategies that followed.

ID generation was not simply a matter of creating collision-free values. In Minichat, the ID serves as both the sort key and the consistency reference. That is why I chose Snowflake — to maintain time ordering even in a distributed environment. This decision became one of the foundations behind the concurrency and ordering strategies discussed in this series.