How Hex Viewer is built
Hex Viewer opens any file and renders it as a hex dump — offset, hex bytes, and ASCII — entirely in the browser. This post is about the engineering underneath it: how a table with potentially millions of rows stays fast without a server or a Web Worker, and two bugs that only showed up once real bytes were pushed through it.
Tech used
Windowed (virtualized) list rendering
The problem this solves: a 50 MB file at 16 bytes per row is a table with over 3 million rows. If you render that as 3 million real <tr> elements, the browser has to lay out, paint, and hold in memory 3 million DOM nodes — the tab will hang or crash long before you scroll anywhere.
The fix is the same one virtualized lists everywhere use (React Window, browser DevTools’ own network/element panels, etc.): never render rows that aren’t on screen. The container that scrolls has a fixed pixel height per row, so given the current scrollTop, the viewport’s pixel height, and the row height, you can compute exactly which row indices are currently visible with plain arithmetic — no need to actually measure anything:
const firstVisible = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight);
const startIndex = Math.max(0, firstVisible - overscan);
const endIndex = Math.min(rowCount, firstVisible + visibleCount + overscan);
Only rows [startIndex, endIndex) are actually rendered as <tr> elements — typically a few dozen, regardless of whether the file is 64 bytes or 6 gigabytes. The scrollbar still needs to look and behave like it’s scrolling through the whole dataset, though, so two spacer rows (topPad / bottomPad, sized to startIndex * rowHeight and (rowCount - endIndex) * rowHeight) sit above and below the real rows, giving the scroll container the correct total height without needing the actual content to exist. overscan renders a few extra rows outside the visible window so fast scrolling doesn’t flash empty space before React/Preact catches up on the next scroll event.
Hex Viewer wraps this in a small pure function, computeWindow(), so it’s unit-testable independent of any rendering framework — the same math would work in a canvas-based renderer, a native app, or a completely different UI library.
Reading files with the File API
The file a user picks or drops never leaves the browser: File.arrayBuffer() returns a promise that resolves to the file’s raw bytes as an ArrayBuffer, which gets wrapped in a Uint8Array for indexed byte access (bytes[i] for the byte at offset i). No <form>, no fetch, no upload endpoint — there’s structurally nothing to send the bytes to.
TextDecoder for multi-encoding text
Hex Viewer’s side panel can decode the bytes currently on screen as ASCII, UTF-8, or Latin-1, so a user can read an embedded string properly even when it spans multiple bytes (which the per-byte ASCII gutter, being one character per byte, cannot do). The browser-native TextDecoder API takes a byte array and an encoding label and returns a JS string: new TextDecoder('utf-8', { fatal: false }).decode(bytes) decodes UTF-8, replacing any invalid byte sequences with the U+FFFD replacement character instead of throwing.
Implementation & operational notes
The iso-8859-1 label doesn’t mean what it says
Hex Viewer’s search works by Latin-1-encoding the whole file into a single JS string once, then using the engine’s native String.prototype.indexOf to find byte patterns. The natural way to do that Latin-1 encoding is new TextDecoder('iso-8859-1').decode(bytes) — Latin-1 (ISO-8859-1) maps every byte value 0–255 directly onto the identically-numbered Unicode code point, so this should be a lossless, reversible, 1:1 mapping.
It isn’t, in a browser. The WHATWG Encoding Standard — which is what TextDecoder actually implements, not the IANA/ISO specs by their literal names — aliases the "iso-8859-1" label (and "ascii", "latin1", and several others) to windows-1252 for legacy web-compatibility reasons. Windows-1252 is identical to true ISO-8859-1 everywhere except the byte range 0x80–0x9F, which it repurposes for printable characters like curly quotes, em dashes, and the ellipsis. Concretely: TextDecoder('iso-8859-1').decode(new Uint8Array([0x89])) returns '‰' (U+2030, PER MILLE SIGN), not the C1 control character U+0089 that byte value would suggest.
This surfaced as a genuine test failure, not a hypothetical: a unit test searched for the PNG magic-byte signature (89 50 4E 47 …) in a haystack built with TextDecoder('iso-8859-1'), and it didn’t find a match at offset 0 — because the first byte of the search pattern (built with a separate, direct String.fromCharCode path) didn’t match the code point the haystack had actually produced for that same byte. Two different byte-to-string paths, silently disagreeing on 32 out of 256 possible byte values.
The fix was to stop asking the platform for “Latin-1” and just implement the 1:1 mapping directly:
function bytesToLatin1String(bytes: Uint8Array): string {
const CHUNK_SIZE = 0x8000;
const parts: string[] = [];
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
parts.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)));
}
return parts.join('');
}
String.fromCharCode builds a UTF-16 string directly from numeric code units with no encoding-label indirection, so byte value and code point are guaranteed identical. It’s chunked (32,768 bytes at a time) because spreading an entire multi-megabyte typed array into a single function call risks hitting the engine’s maximum-arguments limit. This same function now backs both the search haystack and the “Latin-1” panel-decoding option, so the tool’s Latin-1 mode is now actually Latin-1, not windows-1252 wearing a Latin-1 label.
Byte search via native string search, not a hand-rolled algorithm
Searching for a short byte pattern inside a potentially huge byte array is the classic substring-search problem. Writing a good general-purpose version (Boyer-Moore, Knuth-Morris-Pratt) by hand is real algorithmic work, and a naive nested-loop scan is O(n·m) — slow enough to visibly stall the UI on a large file.
Hex Viewer sidesteps writing any of that by leaning on a property of String.prototype.indexOf most code never thinks about: it’s a native, heavily-optimized substring search built into the JS engine. If the byte array is re-encoded into a JS string using the 1:1 mapping above (one UTF-16 code unit per byte), then “does this byte pattern occur in this file, and where” becomes exactly “does this short string occur in this long string, and at what index” — which haystack.indexOf(needle, fromIndex) already answers, using the engine’s own optimized implementation, with zero custom search code. lastIndexOf handles backward search the same way. The pattern for text-mode search is built by UTF-8-encoding the query with TextEncoder first, which — since ASCII is a strict subset of UTF-8 — also transparently covers plain-ASCII queries with no special-casing.
The one-time cost is building the haystack string (computed lazily, only when a search actually happens, and cached per loaded file so repeated searches don’t redo it).
A flexbox bug that quietly defeated virtualization
The fullscreen viewer lays the hex table and a side panel (jump-to-offset, search, decoded text) out side by side using flexbox, with flex-wrap: wrap on the row container so it could fall back to stacking on narrow viewports without a media query.
That single flex-wrap: wrap broke the height chain the virtualization above depends on. The scroll container needs a definite, bounded height so that viewportHeight in the windowing math reflects the actual visible area — the whole point being that only rows fitting in that bounded height get rendered. Flexbox normally provides that: a flex item stretches (align-items: stretch, the default) to fill its container’s cross-axis size. But per the flexbox specification, when flex-wrap: wrap is enabled — even for a container that only ever has one line of items — each line’s cross-size is first computed from its items’ own content-based hypothetical size, and align-content can only ever add extra space on top of that; it can’t shrink a line back down. Since the hex table’s natural (unconstrained) content height is “however tall 12,500 rows are,” that became the line’s cross-size, which the scroll container then dutifully grew to match — silently defeating the whole windowing mechanism. A 200 KB test file rendered all 12,500 <tr> elements instead of the expected few dozen.
The fix was to drop flex-wrap (default nowrap, which the spec explicitly makes stretch to the container’s own cross-size for a single line) and handle narrow viewports with an explicit @media query that switches to flex-direction: column instead. This was caught by an e2e test asserting the rendered row count stays under 100 for a large file — type-checking and a visual glance at the layout both looked completely fine, since the overflow was invisible unless you specifically counted DOM nodes against a big enough file.
No Web Worker, no third-party engine
Everything — hex/ASCII formatting, magic-byte sniffing, search — is plain synchronous TypeScript with no runtime dependency beyond Astro/Preact. This is a deliberate difference from image-conversion siblings in the same family that decode formats like HEIC in a Web Worker with a WASM library: there, the decode step is genuinely CPU-heavy and must not block the main thread. Here, the per-row formatting cost is bounded by the render window (a few dozen rows), not by file size, and search is a single native string call — so there’s nothing to move off the main thread.
Try it / source
- Tool: Hex Viewer
- Source: github.com/GeppettoAndRomero/hex-viewer