Fastest Uint8Array to Hex String conversion in JavaScript

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.

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

  • Native code programmers might have expected a version with a fixed size String, unfortunately that version performed so poorly that it would be ranked at 99.75% slower.

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:

MethodOps/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 StringBuilder34887.21 +- 0.31%86.93 %
#3.1: Array.map() with 4-bit LUT and String Concat35465.37 +- 0.37%86.71 %
#4: Array.map() with 8-bit LUT48936.74 +- 0.70%81.66 %
#5: Array.push() with 8-bit LUT46378.04 +- 0.55%82.62 %
#5.1: Array.set() with 8-bit LUT59356.56 +- 0.59%77.76 %
#6: String Concat with 4-bit LUT and StringBuilder71194.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 LUT266856.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

Bookmark the permalink.