StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: MurmurHash3_64

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: MurmurHash3_64

Introduction

src/shared/murmurhash3.js implements the small, non-cryptographic fingerprint used in several PDF.js subsystems. Its only export, MurmurHash3_64, keeps two 32-bit state words and returns 64 bits formatted as 16 hexadecimal digits.

The name should not be read as a promise of cryptographic security, encryption, or the 128-bit MurmurHash3 x64 variant. This is PDF.js's MurmurHash3-derived 64-bit implementation for inexpensive internal identities and change detection. Its details also differ from a generic streaming hash API: input encoding, call partitioning, and finalization all affect the result.

Module Overview

PartResponsibility
SEED, MASK_HIGH, MASK_LOWSupply the default state and support 32-bit multiplication in JavaScript
constructor(seed)Initialize h1 and h2 from the same seed bits
update(input)Normalize a string or TypedArray, mix complete four-byte blocks, then mix the tail
Four-byte block loopAlternate blocks between h2 and h1
Tail switchIncorporate one to three remaining bytes without fallthrough duplication
hexdigest()Finalize local copies of the states and return two padded 32-bit hexadecimal words

There is no dependency on PDF parsing or browser APIs. The class is a small numeric utility whose callers decide which object properties or bytes belong in a fingerprint.

constructor(seed): One Seed, Two State Words

const SEED = 0xc3d2e1f0;
 
constructor(seed) {
  this.h1 = seed ? seed & 0xffffffff : SEED;
  this.h2 = seed ? seed & 0xffffffff : SEED;
}

Without a seed, both lanes start at 0xc3d2e1f0. A truthy seed is reduced to its low 32 bits and copied into both h1 and h2. Because the test is truthiness rather than seed === undefined, passing 0 selects the default seed instead of creating a distinct zero-seed instance.

The default is fixed, not random. That is appropriate for reproducible internal fingerprints, but it does not protect against deliberately chosen collisions. Bitwise operations elsewhere in the class freely interpret the state as signed or unsigned 32-bit values; the bit pattern, rather than JavaScript's displayed sign, is what matters.

update(input): Normalize Before Hashing

Strings use UTF-16 code units, not UTF-8

For a string, update allocates two bytes per JavaScript code unit, then chooses the actual encoded length:

const code = input.charCodeAt(i);
if (code <= 0xff) {
  data[length++] = code;
} else {
  data[length++] = code >>> 8;
  data[length++] = code & 0xff;
}

A code unit up to 0xff becomes one byte. A larger code unit becomes two bytes, high byte first. Consequently, é (U+00E9) contributes one byte e9, not its two-byte UTF-8 form, while U+0100 contributes 01 00. Surrogate pairs are processed as two separate UTF-16 code units; there is no Unicode normalization.

TypedArrays are copied and measured in bytes

An ArrayBuffer.isView(input) match takes the TypedArray branch:

data = input.slice();
length = data.byteLength;

The slice() copy is significant. It starts a fresh buffer at offset zero and includes only the current view's elements, so two different views into one larger ArrayBuffer do not accidentally hash unrelated neighboring bytes. The subsequent four-byte view is therefore built over exactly the copied view data.

Two input boundaries follow directly from this implementation. DataView passes ArrayBuffer.isView but has no slice() method, so passing the guard does not make it usable. Also, the tail code later indexes data[...] as typed elements rather than through a separate Uint8Array: Uint8Array is the byte-exact path for arbitrary lengths, while wider or signed views must not be assumed to share those tail semantics. The repository's Uint32Array test is block-aligned and has no tail.

A raw ArrayBuffer does not pass the view check and falls into the explicit invalid-format error. Other unsupported inputs throw Invalid data format, must be a string or TypedArray. In practice, callers supply strings and byte-oriented TypedArrays or subarrays; this is not an object serializer, and hashing an object requires the caller to define a string or byte representation first.

Four-Byte Blocks: Alternating Lanes and 32-Bit Mixing

const blockCounts = length >> 2;
const tailLength = length - blockCounts * 4;
const dataUint32 = new Uint32Array(data.buffer, 0, blockCounts);

Each complete block is four bytes. Uint32Array interprets those bytes in the platform's native order; the source explicitly declines to normalize endianness here. The loop then applies the same Murmur-style sequence to every block:

  1. Multiply by C1 = 0xcc9e2d51.
  2. Rotate left by 15 bits.
  3. Multiply by C2 = 0x1b873593.
  4. XOR the block into a state lane.
  5. Rotate that lane left by 13 bits, multiply by 5, and add 0xe6546b64.

The lane choice is easy to misread: even block indexes update k2 and then h2; odd indexes update k1 and then h1. This alternation distributes consecutive blocks across the two 32-bit states rather than maintaining four lanes as the 128-bit algorithm does.

JavaScript numbers are doubles, so a full product of two arbitrary 32-bit values can exceed the exact-integer range. MASK_HIGH = 0xffff0000 and MASK_LOW = 0xffff split the multiplication into high and low contributions, and bitwise operators coerce the result back to 32 bits. The masks are therefore part of the hash arithmetic, not input sanitization.

The Tail Block: Byte-Oriented Input and the TypedArray Boundary

After the complete blocks, tailLength can be zero, one, two, or three. For strings and Uint8Array, data[...] addresses individual bytes, and the switch deliberately falls through from case 3 to case 2 to case 1:

  • three tail bytes place the last byte at bits 16–23;
  • the next byte is placed at bits 8–15;
  • the first byte is XORed into the low eight bits.

The assembled k1 goes through the same C1, 15-bit rotation, and C2 multiplication as a full block. It is then XORed into h1 when blockCounts is odd, and into h2 otherwise—the next lane in the alternating sequence. A zero-length tail executes no mixing step.

The tail is not padded into a fictitious four-byte input block, and no length field is appended. For byte-oriented input, ignoring this path would give identical intermediate states to inputs that differ only in their final one to three bytes.

For wider TypedArrays, however, tailLength is calculated from byteLength while these expressions index elements. A three-element Uint16Array has six bytes but no data[4] or data[5]; those tail reads produce undefined, which the bitwise operations coerce to zero, and its third element is not consumed by this path. This is an implementation boundary, not general byte-hash semantics: callers needing byte-exact behavior for arbitrary lengths should supply a Uint8Array.

hexdigest(): Finalize Copies, Then Format 64 Bits

hexdigest() begins with local h1 and h2 variables. It cross-mixes the lanes with h1 ^= h2 >>> 1, additional multiplications and coupled high/low terms, repeats that mixing stage with another pair of constants, and performs a final h1 ^= h2 >>> 1. The constants include 0xed558ccd, 0xff51afd7, 0x1a85ec53, and 0xc4ceb9fe, with additional 16-bit split and cross-lane terms used by the precision workaround.

Only the returned text is exposed:

return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');

Each lane is converted to an unsigned 32-bit value and padded independently to eight lowercase hexadecimal digits. Concatenation preserves leading zeros and always produces a 16-digit string.

Finalization does not assign the local values back to this.h1 or this.h2. Calling hexdigest() twice without another update returns the same text; calling update() later resumes from the saved, pre-finalization states. There is also no total-input-length accumulator in this finalizer, another reason not to substitute a different Murmur implementation and expect matching output.

The repository's unit test provides a concrete reference:

const hexDigestExpected = 'f61cfdbfdae0f65e';
const sourceText = 'test';
const hash = new MurmurHash3_64();
hash.update(sourceText);
expect(hash.hexdigest()).toEqual(hexDigestExpected);

A Uint8Array containing [116, 101, 115, 116] produces the same digest because it normalizes to the same four bytes.

update() Across Calls: State Accumulates, Tail Bytes Do Not

Although update() preserves h1 and h2 on the instance, it does not retain an incomplete block between calls. Every call starts again at byte offset zero, computes a fresh blockCounts, consumes its own tail, and chooses the tail lane from that call's block count. The class also stores no cumulative byte length.

Therefore, splitting input at an arbitrary boundary is not guaranteed to be equivalent to hashing the concatenation in one call. As a schematic relation:

update(A); update(B)  ≠  update(concat(A, B)) in general

This is not a conventional arbitrary-chunk streaming contract. Existing callers control the sequence of logical values they feed into an instance; readers should treat that update partitioning as part of the fingerprint definition.

Real Uses in PDF.js

CallerWhat is hashedPurpose of the result
OptionalContentConfig.getHash()Optional-content group IDs and visibilityDetect whether initial visibility has changed
AnnotationStorageSerialized editors or modified annotation IDsAttach compact state fingerprints to stored data
PartialEvaluator.preEvaluateFont()Encoding, character range, ToUnicode data, widths, and related font characteristicsReuse font IDs through fontAliases
PdfEditor.#resourceStreamKey()A serialized resource dictionary, byte length, and sampled byte chunksSelect resource-stream deduplication candidates

The editor call site makes the collision boundary explicit: its hash is only a bucket key, and candidates are subsequently compared byte for byte before merging. That additional safeguard belongs to that caller, not to MurmurHash3_64 itself.

Across all these uses, the digest is a compact probabilistic identifier. It is not suitable for password storage, authentication, digital signatures, or integrity checks against an attacker who can engineer collisions.

Summary

MurmurHash3_64 combines deterministic seed initialization, PDF.js-specific string normalization, alternating 32-bit block lanes, explicit tail handling for byte-oriented input, and a non-mutating finalizer that emits two padded hexadecimal words.

Its implementation details are part of its compatibility contract. The default-seed truthiness check, native-endian block view, per-call tail handling, and lack of cumulative length all matter when reproducing digests. PDF.js uses this inexpensive hash for internal state and identity—not as a cryptographic primitive.

StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.

Related articles