--- title: "Convert Uint8Array to Hex quickly in JS" category: Blog tags: ["JavaScript", "TypedArray", "WordPress Archive"] ---

As a Programmer I have to deal with a number of programming languages to write code, and one language that repeatedly appears is JavaScript. JavaScript is one of the weirder languages - similar to PHP in weirdness - which makes it an interesting experience to say the least. Most of the time you're at the whim of a grey box compiler, due to the massive variance of Browsers and Devices that the users use.

As of 2024 there are even faster options and this article has even been turned into a proper NPM modules. Not listed is a variant of the code that is NodeJS specific which uses Buffer.from().text() instead of TextDecoder.

So in order to best approach reality, I have to figure out which APIs are available at any point in time, and also run performance benchmarks in current major browsers available to me. And that's what todays post is about, finding which of the various methods is fast enough for high performance use.

The different Methods

Like any other programming language, there are infinite ways to reach the same solution. Some slower, some unreadable and some look like magic. Here are all the unique ones that I could find or come up with, excluding those which did not even manage to convert more than 1000 buffers per second on current generation hardware:

All code below is under the BSD 3-Clause license.
Copyright 2020 Michael Fabian 'Xaymar' Dirks <info@xaymar.com>

Method #1: Array.map() with String.slice()

function toHex(buffer) { return Array.prototype.map.call(buffer, x => ('00' + x.toString(16)).slice(-2)).join(''); }

While this one looks complex at first, it's actually just calling the map method of a different class on a different object, which just so happens to work. The rest is simple string modification and then joining the entire array to a string.

Method #2: Array.map() with String.padStart()

function toHex(buffer) { return Array.prototype.map.call(buffer, x => x.toString(16).padStart(2, '0')).join(''); }

Same idea as #1, just optimizing the string operations slightly.

Method #3: Array.map() with 4-bit LUT and StringBuilder

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; // End Pre-Init function toHex(buffer) { return Array.prototype.map.call(buffer, x => `${LUT_HEX_4b[(x >>> 4) & 0xF]}${LUT_HEX_4b[x & 0xF]}`).join(''); }

This approach uses a precomputed look-up-table (LUT) to convert any 4-bit value to a hexadecimal symbol.

Method #3.1: Array.map() with 4-bit LUT and String Concat

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; // End Pre-Init function toHex(buffer) { return Array.prototype.map.call(buffer, x => (LUT_HEX_4b[(x >>> 4) & 0xF] + LUT_HEX_4b[x & 0xF])).join(''); }

Method #4: Array.map() with 8-bit LUT

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; const LUT_HEX_8b = new Array(0x100); for (let n = 0; n < 0x100; n++) { LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xF]}${LUT_HEX_4b[n & 0xF]}`; } // End Pre-Init function toHex(buffer) { return Array.prototype.map.call(buffer, x => LUT_HEX_8b[x]).join(''); }

Same idea as #3, but with a LUT to convert any 8-bit value to a hexadecimal symbol group.

Method #5: Array.push() with 8-bit LUT

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; const LUT_HEX_8b = new Array(0x100); for (let n = 0; n < 0x100; n++) { LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xF]}${LUT_HEX_4b[n & 0xF]}`; } // End Pre-Init function toHex(buffer) { const out = new Array(); for (let idx = 0; idx < buffer.length; idx++) { out.push(LUT_HEX_8b[buffer[idx]]); } return out.join(''); }

Breaking out from the same idea is #5, which builds an array manually instead of letting the JavaScript runtime handle it for us. This also uses the LUT approach.

Method #5.1: Array.set() with 8-bit LUT

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; const LUT_HEX_8b = new Array(0x100); for (let n = 0; n < 0x100; n++) { LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xF]}${LUT_HEX_4b[n & 0xF]}`; } // End Pre-Init function toHex(buffer) { const out = new Array(buffer.length); for (let idx = 0; idx < buffer.length; ++idx) { out[idx] = (LUT_HEX_8b[buffer[idx]]); } return out.join(''); }

Method #6: String Concat with 4-bit LUT and StringBuilder

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; // End Pre-Init function toHex(buffer) { let out = ''; for (let idx = 0; idx < buffer.length; idx++) { let n = buffer[idx]; out += `${LUT_HEX_4b[(n >>> 4) & 0xF]}${LUT_HEX_4b[n & 0xF]}`; } return out; }

Similar to #5, but this time we directly build a string instead of building an array first.

Method #6.1: String Concat with 4-bit LUT (String += String + String)

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; // End Pre-Init function toHex(buffer) { let out = ''; for (let idx = 0; idx < buffer.length; idx++) { let n = buffer[idx]; out += LUT_HEX_4b[(n >>> 4) & 0xF] + LUT_HEX_4b[n & 0xF]; } return out; }

Method #6.2: String Concat with 4-bit LUT (2x String += String)

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; // End Pre-Init function toHex(buffer) { let out = ''; for (let idx = 0; idx < buffer.length; idx++) { let n = buffer[idx]; out += LUT_HEX_4b[(n >>> 4) & 0xF]; out += LUT_HEX_4b[n & 0xF]; } return out; }

Method #7: String Concat with 8-bit LUT

Similar to #6, but with an 8-bit LUT. This is effectively this StackOverflow answer, just much cleaner.

// Pre-Init const LUT_HEX_4b = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; const LUT_HEX_8b = new Array(0x100); for (let n = 0; n < 0x100; n++) { LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xF]}${LUT_HEX_4b[n & 0xF]}`; } // End Pre-Init function toHex(buffer) { let out = ''; for (let idx = 0, edx = buffer.length; idx < edx; idx++) { out += LUT_HEX_8b[buffer[idx]]; } return out; }

Omitted Methods

The Results

As usual, tests were run on my daily available machines, mainly the 3950X gaming/development PC. These tests were run using JSBench.me, as JSBen.ch had wildly fluctuating results in both Chrome and Firefox. Without and further needless text, here are the result:

Method Ops/s % Slower
#1: Array.map() with String.slice() 15589.45 +- 1.03% 94.16 %
#2: Array.map() with String.padStart() 17072.61 +- 0.81% 93.60 %
#3: Array.map() with 4-bit LUT and StringBuilder 34887.21 +- 0.31% 86.93 %
#3.1: Array.map() with 4-bit LUT and String Concat 35465.37 +- 0.37% 86.71 %
#4: Array.map() with 8-bit LUT 48936.74 +- 0.70% 81.66 %
#5: Array.push() with 8-bit LUT 46378.04 +- 0.55% 82.62 %
#5.1: Array.set() with 8-bit LUT 59356.56 +- 0.59% 77.76 %
#6: String Concat with 4-bit LUT and StringBuilder 71194.39 +- 0.44% 73.32 %
#6.1: String Concat with 4-bit LUT (String += String + String) 106905.18 +- 0.62% 59.94 %
#6.2: String Concat with 4-bit LUT (2x String += String) 135382.25 +- 0.58% 49.27 %
#7: String Concat with 8-bit LUT 266856.91 +- 0.54% 0.00 %
Tests performed in Mozilla Firefox 83.0 (64-bit) on an AMD Ryzen 3950X with 64GB memory. Similar results were observed in Google Chrome 87.0.4280.88.

Much to my surprise, the String concatenation ones came out on top. Both ended up being roughly 400% faster than their Array based counterparts, which is totally unexpected in this situation. This seems to point at the Array.join() function being poorly implemented in every JavaScript engine, resulting in massive slow downs where barely any should be.

The results slightly differed between Chrome and Firefox on Desktop, where Chrome performed much worse in tests #1, #2, #3, #3.1 and #4, and better in #5 and #5.1. The same relative performance numbers were observed on mobile in both browsers, which most likely also extend to the Apple platforms. Anyway, with all that text out of the way, it's safe to say that method #7 won the contest, by a large margin - even on mobile.

- Xaymar