Global Unique ID Generator Performance: Go and Kotlin

I mostly write backend code in Kotlin, but recently I wanted to understand Go with code I actually own. Go is usually described as lightweight, fast to develop with, and fast at runtime. I wanted a small but nontrivial benchmark topic where those claims would meet real implementation details.
Global Unique ID generators were a good fit for three reasons:
- Generators are performance-sensitive.
- The generated IDs must be globally unique.
- The implementation must stay correct under concurrency.
That means the target is not simple throughput. The implementation also has to preserve uniqueness and concurrency safety. It looks small from the outside, but that mix makes it a useful benchmark subject.
The original goal was simple: implement idgenerators in bluetape-go, then compare the result with
the existing bluetape4k-idgenerators module. I expected Go to lead many workloads because the
implementation can stay close to primitives and small structs.
The benchmark did not end that neatly. Go was still reading random entropy too often. Kotlin was
creating intermediate objects in Snowflake. ULID, KSUID, and KsuidMillis each had different
hot spots. The work became a three-phase comparison.
- Build the Go implementation and run the first comparison.
- Reduce Go per-call overhead and entropy cost, then run the second comparison.
- Improve Kotlin Snowflake, ULID, KSUID, and KsuidMillis, then run the third comparison.
The result is not a clean “Go is faster” or “Kotlin won” story. Each ID family moved for a different reason, and every optimization made the next bottleneck easier to see.
The bluetape-go ID Generator Implementation
Section titled “The bluetape-go ID Generator Implementation”bluetape-go keeps the implementation in the id package.
- UUID v4/v7 expose convenience functions and reusable generators.
- ULID is supported.
- KSUID includes the regular seconds-based variant plus a millisecond variant aligned with the JVM implementation.
- Snowflake exposes a 64-bit integer ID entrypoint.
The API shape keeps direct string entrypoints for application code and 64-bit integer entrypoints for workloads where the benchmark should not mix string allocation into ID generation.
id.NewULIDMonotonicGenerator().NextString()id.NewKSUIDGenerator().NextString()id.NewKSUIDMillisGenerator().NextString()id.NewSnowflakeGenerator(...).NextInt64()The code lives in bluetape-go/id
and id_benchmark_test.go.
The first benchmark note is
Issue #168 ID generator benchmark.
First Comparison: Not as Simple as Expected
Section titled “First Comparison: Not as Simple as Expected”The first benchmark did not give Go every workload. Go was much faster on the Snowflake synthetic-clock core generation path. Kotlin was already strong on UUID v7, single-thread monotonic ULID, and KSUID seconds.

The benchmark shape matters here. The Go Snowflake workload uses a synthetic clock hook. The Kotlin
Snowflake workload uses the real system clock and a kotlinx-benchmark batch shape. That result is useful
as a core-path data point, but it should not be read as a production-equivalent language verdict.
Still, the first comparison did its job. The Go profile showed that generator reuse alone was not enough. The larger cost was lower in the stack: random entropy reads and string encoding.
Go Improvements: Entropy Came Before Allocation
Section titled “Go Improvements: Entropy Came Before Allocation”The Go optimization had two main changes.
First, the default entropy source changed. If UUID, ULID, and KSUID read crypto/rand for every
ID, the benchmark starts measuring random-read overhead more than ID generation. The package now
uses a local locked buffered reader for the default generators. The source still comes from
crypto/rand. The report also records the boundary: these are identifiers, not authentication or
authorization secrets.
Second, KSUIDMillis avoids an unnecessary temporary slice in string encoding. It writes the
27-character prefix into a fixed local buffer instead of building a larger output and slicing it
afterward.
The final benchstat summary was clear. Geomean latency dropped by 58.34%. UUID v4 reused moved
from 224.10 ns/op to 45.57 ns/op, and KSUID millis moved from 316.80 ns/op to 122.80 ns/op. By
contrast, ULID monotonic barely moved: 65.80 ns/op to 65.31 ns/op.
The flat ULID monotonic workload is important. That path was no longer dominated by entropy reads.
State management and encoding were more visible. One optimization did not move every generator.
The full Go optimization report is
Issue #192 ID generator performance optimization.
Second Comparison: The Bottleneck Moved
Section titled “Second Comparison: The Bottleneck Moved”After the Go improvement, the comparison changed. Go led UUID v4, KSUID millis, and the concurrent ULID/KSUID workloads. Kotlin still led UUID v7 and single-thread monotonic ULID.

The useful reading is not just “Go got faster.” The bottleneck moved. Once random entropy reads were reduced, Go started leading UUID v4 and KSUID millis. Kotlin still kept UUID v7 and single-thread monotonic ULID. The chart is already too mixed for a single-runtime verdict.
At that point, the Kotlin side deserved another pass. Once the Go noise was reduced, Kotlin’s object path and entropy handling became easier to see.
Kotlin Improvements: Snowflake, ULID, KSUID, KsuidMillis
Section titled “Kotlin Improvements: Snowflake, ULID, KSUID, KsuidMillis”The Kotlin work happened in bluetape4k-idgenerators. The target was to keep the public API stable
while reducing unnecessary objects and random payload steps.
Snowflake used to create a SnowflakeId intermediate object and then extract the Long. The new
internal value generation path lets nextId() and nextIds(size) receive the value directly when
the sequencer supports it.
internal interface SnowflakeValueSequencer { fun nextValue(): Long}This does not show up much in throughput. The benchmark generates 65,536 IDs per operation, while Snowflake has a 4,096 sequence-per-millisecond ceiling. With the real system clock, the benchmark hits that ceiling first. The allocation profile is the real win:
| Snowflake profile | Baseline | Candidate 3 | Delta |
|---|---|---|---|
gc.alloc.rate.norm | 14,579,592.553 B/op | 6,970,033.101 B/op | -52.19% |
gc.alloc.rate | 868.813 MB/s | 415.382 MB/s | -52.19% |
gc.count | 6 | 3 | -50.00% |
ULID no longer stages randomness through a ByteArray(10). It writes the 80 random bits directly
from nextBits(16) and nextLong().
KSUID and KsuidMillis now write the timestamp explicitly, then randomize only the payload bytes.
The seconds-based variant randomizes the 16-byte payload after the 4-byte timestamp. The
millisecond variant randomizes the 12-byte payload after the 8-byte timestamp.
Candidate 3 moved concurrent ULID the most. concurrent.ulidMonotonicString improved by 18.48%,
and the uniqueness workload improved by 29.02%. KSUID and KsuidMillis moved more narrowly because the
payload randomization change was only one part of their total cost. That movement shows up again in
the Phase 3 comparison.
One workload needs a caveat. single.ksuidMillisDefaultString dipped in the full candidate 3 run, but
a same-JVM targeted repeat measured only -1.74% from baseline. Since the KSUID millis path did not
change between candidate 2 and candidate 3, the report keeps that full-run dip as benchmark noise
instead of hiding it. Once bad workloads are deleted from a performance report, it stops being a
benchmark and starts being a brochure.
Third Comparison: Each Runtime Leads Somewhere Different
Section titled “Third Comparison: Each Runtime Leads Somewhere Different”The final comparison used the current Go implementation and Kotlin candidate 3. Kotlin throughput
entries were normalized to ns/id with 1e9 / (ops/s * 65_536).

KsuidMillis is a separate variant from seconds-based KSUID because it uses different timestamp
precision. The seconds-based result should not stand in for it. After the Go pass, KSUID
millis had a clear Go lead. After Kotlin randomized only the payload region, the concurrent workload
became a practical tie. Go still leads the single-thread workload. This variant needs its own measurement
whenever millisecond precision is part of the service design.
ULID is also split. Kotlin has the lower ns/id and is faster in the single-thread monotonic workload, while Go has the lower ns/id and is faster in the concurrent monotonic workload. The cost depends on where monotonic state, entropy, and encoding show up in the workload.
Kotlin leads both KSUID seconds workloads after the improvement. Snowflake still looks much lower on the Go side, but the clock model caveat dominates that workload. Kotlin’s real Snowflake win in this pass is allocation reduction, not a throughput ranking.
The Useful Conclusion
Section titled “The Useful Conclusion”The conclusion that needs the most care is “which language is faster?” That question alone does not explain the benchmark result.
The useful conclusions are more specific:
- Snowflake needs clock model and sequence ceiling separated from language comparison.
- Go’s early UUID/ULID/KSUID bottleneck was closer to entropy reads than generator reuse.
- Kotlin Snowflake improved most clearly in allocation profile, not throughput.
- Kotlin ULID/KSUID/KsuidMillis moved when payload handling was reduced.
KsuidMillisneeds to be tracked as its own variant; KSUID seconds cannot stand in for it.
ID generator code looks small. The benchmark is not. Clock access, randomness, encoding, uniqueness checks, and batch size all pull the result in different directions. The best outcome of this work is not one fast workload. It is knowing where to look first the next time an ID generator number looks suspicious.
Source Notes
Section titled “Source Notes”- Go implementation:
bluetape-go/id - Go benchmark source:
id_benchmark_test.go - First comparison report:
Issue #168 ID generator benchmark - Go optimization report:
Issue #192 ID generator performance optimization - Kotlin optimization report:
ID Generators Self-Improve Benchmark - Kotlin raw artifacts:
docs/benchmarks/raw/issue-738 - Third comparison report:
Issue #192 ID generator third comparison - Third comparison raw artifacts:
docs/research/outputs/issue-192
Comments
Leave a note or reaction with your GitHub account.