ULID is usually described as "sortable by generation order because the timestamp comes first," but that's only half true. Generate several ULIDs within the same millisecond and the timestamp portion comes out identical every time, so ordering ends up decided entirely by the random part — with no relation to the order they were actually created in. ULID's spec has an answer for this: a "monotonic" mode. But it only kicks in if you explicitly call a different function from the default one. Let's read the source of the ulid npm package to see what's actually happening.

Why ordering breaks in the default mode

A ULID concatenates a 48-bit millisecond timestamp with an 80-bit random value, then encodes the result as 26 characters of Crockford's Base32. String comparison matches timestamp comparison only because the first 10 characters are the timestamp. Call the generator function returned by factory() more than once within the same millisecond, though, and those first 10 characters stay identical while the remaining 16 characters (80 bits) get fresh random bits on every call. Since the string ordering is then decided entirely by that random part, it's entirely normal for the third ULID generated to sort before the first one. A millisecond sounds short, but any loop that issues IDs in a batch will routinely land multiple calls inside the same one.

How monotonicFactory increments the random part

monotonicFactory in the ulid package keeps the previous timestamp and random part in a closure, and only increments the random part when the timestamp passed to the next call is less than or equal to the previous one.

return function ulid(seedTime) {
  if (isNaN(seedTime)) {
    seedTime = Date.now();
  }
  if (seedTime <= lastTime) {
    const incrementedRandom = (lastRandom = incrementBase32(lastRandom));
    return encodeTime(lastTime, TIME_LEN) + incrementedRandom;
  }
  lastTime = seedTime;
  const newRandom = (lastRandom = encodeRandom(RANDOM_LEN, currPrng));
  return encodeTime(seedTime, TIME_LEN) + newRandom;
};

The check is seedTime <= lastTime, not seedTime === lastTime. So if the system clock ever moves backward, the function still treats that as "no time has passed" and increments the random part anyway. The monotonic guarantee comes from the order in which this function is called, not from the clock itself.

The increment logic, incrementBase32, treats the 16-character random part as a base-32 number written in Crockford's alphabet and adds 1 starting from the least significant character.

export function incrementBase32(str) {
  let done = undefined;
  let index = str.length;
  let char;
  let charIndex;
  const maxCharIndex = ENCODING_LEN - 1;
  while (!done && index-- >= 0) {
    char = str[index];
    charIndex = ENCODING.indexOf(char);
    if (charIndex === maxCharIndex) {
      str = replaceCharAt(str, index, ENCODING[0]);
      continue;
    }
    done = replaceCharAt(str, index, ENCODING[charIndex + 1]);
  }
  if (typeof done === "string") {
    return done;
  }
  throw createError("cannot increment this string");
}

If the least significant character is Z (the highest value in Crockford's Base32), it wraps to 0 and carries into the character to its left — the same carrying you'd do by hand with decimal addition, just done in base 32. This is what guarantees that every ULID generated within the same millisecond sorts strictly after the previous one.

What happens when all 80 bits are exhausted

The while loop in incrementBase32 only finishes successfully if it finds a character it can increment without carrying further. If all 16 characters are already Z, the carry propagates past the first character, done never becomes a string, and the function reaches throw createError("cannot increment this string"). In other words, once you exhaust the roughly 1.2 × 10^24 combinations available in 80 bits within a single millisecond, the generator doesn't roll the timestamp forward — it throws and stops. No real application is going to generate 2^80 ULIDs in one millisecond, but it's worth knowing the design isn't "automatically bump the timestamp once exhausted." There's no infinite-retry fallback built in.

How UUID v7 handles this

RFC 9562, which defines UUID v7, only lists three optional implementation strategies for ordering within the same millisecond and leaves the choice to whoever implements the library. Among the candidates are a fixed-width counter placed in the rand_a field, and an approach that — like ULID — increments the random bits themselves. None of these is mandated by the spec. ULID, by contrast, names "Monotonic ULIDs" explicitly in its spec and pins down the increment-the-random-part algorithm at the spec level itself. In the sense that behavior doesn't vary by implementation, ULID has gone further than UUID v7 in actually deciding how same-millisecond ordering should work.

What this means in practice

If you're using ULIDs as database primary keys or event log IDs and any code path issues a burst of them in a short window — bulk inserts, tight loops — you need monotonicFactory() rather than the default factory(). If a given path only ever issues one ULID per incoming request, two calls landing in the same millisecond is unlikely enough that the default mode causes no practical problem. Either mode produces an identical-looking timestamp portion, so you can't tell which one was used just by looking at the string. If you want to see the random part increment by one in real time, you can generate several ULIDs against the same fixed millisecond and toggle monotonic mode on and off with the tool below.