PDF.js Source Walkthrough: MurmurHash3_64
October 27, 2026 · StreamPDF Team

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
| Part | Responsibility |
|---|---|
SEED, MASK_HIGH, MASK_LOW | Supply 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 loop | Alternate blocks between h2 and h1 |
Tail switch | Incorporate 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:
- Multiply by
C1 = 0xcc9e2d51. - Rotate left by 15 bits.
- Multiply by
C2 = 0x1b873593. - XOR the block into a state lane.
- 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 generalThis 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
| Caller | What is hashed | Purpose of the result |
|---|---|---|
OptionalContentConfig.getHash() | Optional-content group IDs and visibility | Detect whether initial visibility has changed |
AnnotationStorage | Serialized editors or modified annotation IDs | Attach compact state fingerprints to stored data |
PartialEvaluator.preEvaluateFont() | Encoding, character range, ToUnicode data, widths, and related font characteristics | Reuse font IDs through fontAliases |
PdfEditor.#resourceStreamKey() | A serialized resource dictionary, byte length, and sampled byte chunks | Select 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
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026