Skip to main content
← Projects

How I built a Warehouse Picking Optimizer

I built a warehouse optimizer from scratch, solo, before LLMs: barcode scans, stock selection, pick routes, and inventory updates. In microseconds, it computes virtually optimal constrained routes that'd require hours to solve with a mathematically exact solver.

Sole Engineer · Vivaldi srl · Sep 2019 – Feb 2022 · 22 min read
Node.js Rust/WASM SQL Server Route Optimization Warehouse Systems

TL;DR

What shipped: I was the sole engineer on a barcode-driven warehouse picking system for Vivaldi srl. Operators would scan an order, and the Node.js service would call Rust/WASM optimizer modules around generated floor data, distance lookup, stock-location choice, and route ordering. The same system would also handle inventory search, Excel/CSV import-export, and error reporting on a shared 4 GB air-gapped Debian server.

How I measured it: during the engagement I checked the optimizer with before-and-after route comparisons on real orders and tracked solve times in production. Later, out of my own interest and research, I solidified those checks into a benchmark suite: six scenarios that the exact solver could finish within its time limit, each with 12–20 order lines across 15–40 stocked locations, several placing the same item in more than one location. On this set, solve time moved from milliseconds to microseconds, and the final routes were effectively identical in length to the exact optimum that can take hours to prove.

Role
Sole engineer
Production constraint
Shared 4 GB air-gapped Debian server
Workflow
Barcode scan → stock selection → ordered pick route
Route-core solve time
4.91 ms → 30.3 µs
Route quality
11.53% longer → effectively exact
Distance model
17×31 compile-time grid, 97 positions, 36.8 KB baked tables
Runtime boundary
Rust/WASM optimizer called from Node.js
On this page

Context

Vivaldi’s ERP would print pick lists, not routes. A worker would scan an order, receive a flat list of parts and quantities, and still have to decide how to walk the warehouse. That would be tolerable for small orders. It would break down on batch picks spanning 9 aisles plus a smaller side section, where backtracking became part of the job.

From September 2019 to February 2022 I was the sole engineer on the system that closed that gap: a React client, a Node.js API (Node.js 16 in the last deployed version), internal services, and Microsoft SQL Server shared with the ERP. Operators would drive everything through USB barcode scanners. Beyond routing, the system would handle bidirectional inventory search (position to item, item to positions), export warehouse snapshots to Excel, process bulk imports from Excel/CSV with typed parse and semantic errors, and batch log entries before emailing them to administrators. The Node.js service would also call Rust/WASM optimizer modules, so the routing core could move away from brittle native dependencies without changing the operator workflow.

The operational constraints shaped everything. The server was a shared Debian box with 4 GB of RAM and no outbound internet access. Deployment was manual, three systemd units, no CI/CD, and, at the customer’s explicit request, no Docker. Every dependency with a native build step became a liability, because the C++ toolchain had to exist on the production server just to install the app.

The optimizer was measured two ways. During the engagement I validated the production path against real orders: routes reviewed on the floor, before-and-after comparisons when the optimizer changed, and solve times tracked on every run. Later, out of my own interest and research, I solidified that early evidence into a proper benchmark suite: representative solver profiles in Rust/WASM, compile-time floor-grid validation, generated distance tables, and an exact route-ordering referee for scenarios small enough to solve provably. The exact solver never belonged in the live request path. Its job was to grade the fast heuristic, and some solves needed hours of offline proof work. That split let the system stay practical on a 4 GB server while still giving me defensible numbers: solve time moved from 4.91 ms to 30.3 µs, and the finished routes became effectively identical in length to the provably shortest routes on the benchmark set.


How a Location is Addressed

Every location in the warehouse has a compact code like E5.2. Everything before the dot is the floor position (aisle E, rack 5), and the number after the dot is the shelf tier. Separating the two let me optimize walking distance and ergonomics independently.

E
Aisle
which corridor
5
Rack
how far along
2
Shelf
which tier
E5→ determines walking distance
.2→ determines picking height

Try the warehouse model: play the route once, then click E5 or H2 to see why walking distance and shelf height are separate problems.

Warehouse Map
Ready
Entrance
Arrow keys move · click racks to inspect
Door
Rack
Queued
Picked
Trail
Click a rack to inspect its slots and inventory.
Route manifest · 8 picks
  1. 00StartEntrance
  2. 01A2.5pick
  3. 02B3.2pick
  4. 03A5.2pick
  5. 04C7.3pick
  6. 05E5.1pick
  7. 06I5.4pick
  8. 07H1.3pick
  9. 08H2.6pick
  10. 09ReturnEntrance
Door. Row 16, column 0

From Order to Route

A single item can be stored in multiple slots, so the problem is not just routing. Given a set of requested items with known storage locations and stock levels, the solver must choose which slots to visit, how many units to take from each, and in what order, so that the operator’s tour from the door and back is as short as possible.

The customer added two constraints on top of pure distance. First, prefer ground-level shelf tiers, to reduce reaching and bending. Second, drain nearly-empty slots before touching fuller ones: if an order requests 10 units of an item available at A1.1 (8 in stock) and B2.1 (50 in stock), take the 8 from A1.1 first, so inventory stays consolidated instead of leaving single-digit remnants scattered across the floor.

Without optimization, a batch order might send the operator to C6, across the warehouse to I5, back to H2, then west again for what was missed. An optimized route starts near the door and sweeps through adjacent aisles. The business did not need an optimization score. It needed a pick list an operator could follow on the spot, appearing within seconds of the barcode scan, or the tool would break the workflow it was supposed to speed up.

What Shipped

The production optimizer finished as a five-stage pipeline:

  1. choose stock locations using depletion and shelf-height rules
  2. reduce shelf slots to floor positions for walking distance
  3. use a generated floor model and measured rack-to-rack walking costs
  4. call the route-ordering core from the Node.js service, with Rust/WASM modules where the native boundary had become too brittle
  5. reattach shelf information so ground-level picks appeared first where possible

That shipped under deadline and fit the operator workflow. Its hard part was operational: the same route logic had to run on an air-gapped 4 GB server, avoid fragile install steps, and still return a pick list quickly enough that an operator trusted the scan.

Before any of that runs, the greedy selection stage needs to know which slots are still available and what quantities they hold. That comes from Microsoft SQL Server, the database shared with the ERP, through a repository layer whose only job is handing back plain typed records. Everything downstream of it, grid reduction, distance matrix, TSP solve, and regrouping, is a pure function over those records. Nothing below the repository layer knows SQL Server exists. That boundary is what let me unit-test the whole optimization core against fixtures, with no database to spin up.

The route-ordering boundary changed because the native C++ TSP dependency was a liability. The first version was a pragmatic call under deadline, not an abdication. I cared enough about the underlying algorithm to reimplement its core idea, simulated annealing, for a university algorithms assignment, and I also explored a genetic-algorithm approach to TSP for another exam. But in production the native dependency meant compiling C++ on an air-gapped 4 GB server at every install, and stack traces across the FFI boundary were useless. The Rust/WASM module gave the Node.js service a smaller, typed route-core boundary.

In daily use the system held up: operators would follow the routes, and a solve would come back well within the seconds an operator would tolerate after a scan. But production evidence alone leaves a gap: it tells you whether people can use the route, not how far that route is from the shortest possible one. The benchmark suite I later solidified exists to answer that second question without putting an exact solver in front of an operator.

The Rest of the System

Route optimization was the reason the project existed, but most of the code served everything around it, and a solver without that surrounding product would have been a demo.

Barcode input everywhere. USB barcode scanners are keyboards with extra steps: they type the scanned code and press Enter. Every workflow in the client was designed around that, starting with the order-code scan that would kick off a pick. I unpack the trick, and where it breaks, in Barcode scanners are keyboards with extra steps.

Excel in, Excel out. A full warehouse snapshot would export to Excel, giving the team an offline audit trail. The reverse path mattered more: bulk imports from a canonical spreadsheet would be parsed and validated with typed errors (a malformed quantity is a different failure from a slot that does not exist), streamed to the database through a gRPC updater service, and applied atomically so a bad file could not leave the warehouse half-updated.

An error pipeline instead of silence. On an air-gapped server nobody watches logs. Application errors would flow from the logger through gRPC to a separate emailer service that would batch them and mail administrators. The emailer would run as its own systemd unit, so a crash there could not take the API down with it.


Route Quality and Runtime

The route core needed two properties that tend to fight each other: it had to answer quickly after a barcode scan, and it had to avoid plausible-looking routes that were quietly bad. The full benchmark suite came together after delivery, out of my own interest and research, and doubled as my deep dive into Rust and WebAssembly, the same exploration that later took me to Prisma. I kept representative solver profiles side by side in the same Rust/WASM codebase, so consecutive stages differ in exactly one decision and can be measured against the same exact solver. The numbers isolate one decision at a time: distance storage, slot representation, local route repair, and the WebAssembly boundary back into the Node.js service.

Computing the True Optimum

Every quality number on this page is a gap against the shortest route for the same positions the greedy selector already chose, not against a slightly better heuristic. I am not solving the full joint problem of slot choice plus route order in these benchmarks. I am using an exact referee to grade the route-ordering stage honestly. The ILP is far too slow to ship, but shipping is not its job. The exact solver’s job is simple: for the same chosen slots, find the shortest possible order to visit them and return to the door. With binary variables xijx_{ij} indicating that the route travels directly from position ii to position jj, and cijc_{ij} the precomputed walking distance, the objective is the plain tour cost

minijicijxij.\min \sum_{i} \sum_{j \neq i} c_{ij} \, x_{ij}.

The classic difficulty with TSP as an integer program is subtour elimination. The Miller-Tucker-Zemlin (MTZ) formulation1 handles it with ordering variables. Degree constraints

jixij=1,jixji=1i\sum_{j \neq i} x_{ij} = 1, \qquad \sum_{j \neq i} x_{ji} = 1 \qquad \forall i

give each node exactly one predecessor and one successor, but nothing yet prevents the solution from splitting into disjoint cycles. MTZ adds an integer position variable uiu_i per non-depot node, with u0=0u_0 = 0 and 1uim11 \leq u_i \leq m-1, and the constraints

uiuj+mxijm1ij,  i,j1.u_i - u_j + m \, x_{ij} \leq m - 1 \qquad \forall\, i \neq j,\; i, j \geq 1.

Whenever edge iji \to j is used, ujui+1u_j \geq u_i + 1: positions strictly increase along the tour. A subtour that avoids the depot would need its positions to increase forever around a cycle, a contradiction, so only one tour through the depot survives.

A picker’s route is an open path, not a cycle, so the formulation adds a virtual end node ee: the edge e0e \to 0 is forced and free, the edge 0e0 \to e is forbidden, and when scoring round trips the edge iei \to e carries the cost ci0c_{i0} of walking from pick ii back to the door. That last detail matters more than it looks: a solver that ignores the walk back prefers tours that end in the far corner of the warehouse.

A tour (what TSP solvers expect)
door1234
One cycle through every pick, ending where it started. Subtour-elimination constraints exist to force exactly this shape.
A picker's route (open path + virtual end)
forced, cost 0carries the walk backdoor1234e
The route ends at the last pick. The dashed node e closes the cycle on paper only: every pick may connect to e, e connects back to the door for free, and the door can never jump straight to e.
When the solver scores round trips, the dashed edge into e costs exactly the walk from that pick back to the door, so ending in a far corner is penalized.

I validated the exact solver the boring way: on 8-node instances, brute force over all 7!=5,0407! = 5{,}040 orderings and assert the ILP matches the exhaustive minimum, for both the open-path and round-trip variants. The exact solver can run for hours, so scenarios that hit the timeout are quarantined into a separate stress pack, and the headline quality number only includes routes the ILP solved exactly. An honest quality curve needs an honest denominator.

The Ladder

Every stage builds the route the same way, with the nearest-neighbor rule: start at the door, always walk to the closest remaining pick. What changes from stage to stage is how distances are stored and queried, until the last two stages, which improve the finished route. Distances come in two flavors throughout: a uniform lens, where every floor tile costs the same, and a weighted lens using physical meters. All figures below are cold-start means on the exact-solvable scenario pack, uniform lens, per-order solves of roughly 10–30 picks, measured on Apple Silicon.

Mean gap is the average extra route length versus the exact shortest route for the same picked slots. A gap of 0.00% means the benchmarked routes matched the exact answer at the reported precision.

StageWhat changedMean solveMean gap vs optimalAllocations/solve
MVPgrid parsed at runtime, per-query Dijkstra memoized under (String, String) keys4.91 ms11.53%~6,800
JPS detourper-query Jump Point Search, no memoization (uniform-only)1.00 ms11.53%~5,500
Precomputedbuild-time Dijkstra tables, u32 entries, label-hash lookup45.8 µs11.53%97
Compressedu16 tables + bit-packed SlotId fast path25.5 µs11.53%97
Route refinedreturn-leg-aware 2-opt27.0 µs0.00%97
Current+ or-opt relocation30.3 µs0.00%97

The MVP did what most first versions do: it worked, and it hid its costs in the allocator. Every distance query keyed a HashMap with a pair of heap-allocated strings. A fully warm long-lived process held about 717 KB of memoized distances, roughly 20× the size of the tables that eventually replaced it, and every cold solve paid around 6,800 heap allocations.

The JPS detour. Jump Point Search had shipped in the original system via the pathfinding library, and I also tested it in the Rust/WASM route core. It was, however, the wrong tool, for three reasons. First, JPS’s symmetry pruning is only correct on uniform-cost grids. The moment distances become physical meters (rack bays 2.7 m, corridors at 1.2 m), its core assumption breaks, so it could never serve the weighted distance lens at all. Second, the problem shape is wrong: JPS accelerates single-shot queries on large open maps, but this solver needs all-pairs distances among 97 fixed positions on a 527-cell grid, which wants one precomputation, not a clever per-query search. Third, warehouse grids are corridor-dense, so jump points appear at nearly every cell and the pruning saves little expansion. The visualizer below runs both A* and JPS on the real floor plan, so you can watch how much the pruning actually skips.

A* vs JPS Pathfinding
A*0 / 177 nodes

A* expands 177 nodes from A2 to I5 in this uniform-cost example.

JPS0 / 13 nodes

JPS expands 13 nodes from A2 to I5 in this uniform-cost example.

Start
End
A* expanded
JPS expanded
Jump point

The measurements added a fourth reason the table above hides. JPS looks 5× faster than the MVP, but that is a cold-start comparison. Because JPS recomputes every query from scratch, it cannot benefit from a warm process. In a long-lived process with the same heuristic, the string-keyed memo answered warm solves in about 35 µs while JPS still took about 951 µs, every order, forever. The clever online search loses to a hash map.

Precomputation is where the latency story actually turned: 1.00 ms to 45.8 µs, not from a smarter algorithm but from moving the work to a phase where its cost is irrelevant.

Compiling the Floor Plan

The single source of truth for the floor plan is grid.txt, a 17×31 annotated ASCII grid: 96 rack labels plus the door, walkable cells, and shelf counts. A build.rs script parses it at compile time and runs Dijkstra’s shortest-path algorithm, 8-directional, from all 97 sources, twice: once with tile-uniform costs and once with physical-meter weights. The generated code bakes both all-pairs tables into the binary, so a runtime distance lookup is one array index. Codegen guardrails fail the build if any position is unreachable from any other (a disconnected floor plan) or if a scaled distance overflows u16. A malformed warehouse cannot compile.

grid.txt parsed by build.rs
# Warehouse floor plan, 17 rows x 31 columns
#
# Cell types:
#   0          walkable floor (corridor)
#   1          shelf/obstacle (not walkable)
#   A1:6       rack label : total pallet positions (named pickup point, walkable)
#   @          entry door (implicit tour start)
#   $          depot (reserved for future use)
#   !          exit  (reserved for future use)
#
# Slot naming: "A4.3" = aisle A, rack 4, slot 3
#   Each rack has (N/3) columns × 3 floors.
#   Slots numbered column-by-column, bottom to top:
#     col 1: .1 (ground), .2 (mid), .3 (high)
#     col 2: .4 (ground), .5 (mid), .6 (high)
#     col 3: .7 (ground), .8 (mid), .9 (high)
#     col 4: .10 (ground), .11 (mid), .12 (high)
#   Ground level = (slot - 1) % 3 == 0  →  {1, 4, 7, 10}
#
# Entry door: bottom-left corner (@)

[  0,      0,      0,      0,      0,      B1:6,   1,      1,      C1:6,   0,      0,      0,      D1:6,   1,      1,      E1:6,   0,      0,      0,      F1:6,   1,      1,      G1:6,   0,      0,      0,      H1:12,  1,      1,      I1:12,  0      ]
[  A1:6,   0,      0,      0,      0,      B2:6,   1,      1,      C2:6,   0,      0,      0,      D2:6,   1,      1,      E2:6,   0,      0,      0,      F2:6,   1,      1,      G2:6,   0,      0,      0,      H2:12,  1,      1,      I2:12,  0      ]
[  1,      A2:6,   0,      0,      0,      B3:6,   1,      1,      C3:6,   0,      0,      0,      D3:6,   1,      1,      E3:6,   0,      0,      0,      F3:6,   1,      1,      G3:6,   0,      0,      0,      H3:12,  1,      1,      I3:12,  0      ]
[  1,      A3:6,   0,      0,      0,      B4:6,   1,      1,      C4:6,   0,      0,      0,      D4:6,   1,      1,      E4:6,   0,      0,      0,      F4:6,   1,      1,      G4:6,   0,      0,      0,      H4:12,  1,      1,      I4:12,  0      ]
[  1,      A4:6,   0,      0,      0,      B5:6,   1,      1,      C5:6,   0,      0,      0,      D5:6,   1,      1,      E5:6,   0,      0,      0,      F5:6,   1,      1,      G5:6,   0,      0,      0,      H5:12,  1,      1,      I5:12,  0      ]
[  1,      A5:6,   0,      0,      0,      B6:6,   1,      1,      C6:6,   0,      0,      0,      D6:6,   1,      1,      E6:6,   0,      0,      0,      F6:6,   1,      1,      G6:6,   0,      0,      0,      H6:9,   1,      1,      I6:9,   0      ]
[  1,      A6:6,   0,      0,      0,      B7:6,   1,      1,      C7:6,   0,      0,      0,      D7:6,   1,      1,      E7:6,   0,      0,      0,      F7:6,   1,      1,      G7:6,   0,      0,      0,      H7:9,   1,      1,      I7:9,   0      ]
[  1,      A7:6,   0,      0,      0,      B8:6,   1,      1,      C8:6,   0,      0,      0,      D8:6,   1,      1,      E8:6,   0,      0,      0,      F8:6,   1,      1,      G8:6,   0,      0,      0,      0,      1,      1,      0,      0      ]
[  1,      A8:6,   0,      0,      0,      B9:6,   1,      1,      C9:6,   0,      0,      0,      D9:6,   1,      1,      E9:6,   0,      0,      0,      F9:6,   1,      1,      G9:6,   0,      0,      0,      0,      1,      1,      0,      0      ]
[  1,      A9:6,   0,      0,      0,      B10:6,  1,      1,      C10:6,  0,      0,      0,      D10:6,  1,      1,      E10:6,  0,      0,      0,      F10:6,  1,      1,      G10:6,  0,      0,      0,      0,      0,      0,      0,      0      ]
[  1,      A10:6,  0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      D11:9,  1,      1,      E11:9,  0,      0,      0,      F11:9,  1,      1,      G11:9,  0,      0,      0,      0,      0,      0,      0,      0      ]
[  1,      A11:9,  0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      D12:9,  1,      1,      0,      0,      0,      0,      F12:9,  1,      1,      0,      0,      0,      0,      0,      0,      0,      0,      0      ]
[  0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      1,      1,      0,      0,      0,      0,      0,      1,      1,      0,      0,      0,      0,      0,      0,      0,      0,      0      ]
[  0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      1,      0,      0,      0,      0,      0,      0,      1,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0      ]
[  0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0      ]
[  0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      J5:9,   J4:9,   J3:9,   J2:9,   J1:9,   0      ]
[  @,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      0,      1,      1,      1,      1,      1,      0      ]
Codegen Pipeline
Click any stage to see what it produces

This idea came to me while I was preparing for a Functional Programming exam at university. Parsing a small warehouse DSL felt like a good use case for Haskell’s pattern matching, so I wrote the first grid-updater generator there. It produced the TypeScript grid module for the delivered system. The Rust/WASM route core folded the same idea into build.rs, where the compiler enforces what the Haskell tool could only generate.

Squeezing the Tables: SlotId

The u32 tables weighed 73.6 KB. I cut that in half by observing that no scaled distance on this floor plan exceeds 65,535, so u16 entries were enough: 36.8 KB total, and 32 entries per 64-byte cache line instead of 16. Then I removed label hashing by packing slot labels like G11.12 into three small bounded components inside a u16:

u16 layout (MSB → LSB): [ 4 unused | aisle:4 | rack:4 | shelf:4 ]
"G11.12" → aisle_idx=6, rack_idx=10, shelf_idx=11
→ (6 << 8) | (10 << 4) | 11 = 0x06AB = 1707

Decoding is three branchless shift-and-mask operations, and the round trip is lossless:

SlotId: aisle/rack/shelf in 12 bits of a u16
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SlotId(u16);

impl SlotId {
    #[inline]
    pub fn aisle(&self) -> u8 { ((self.0 >> 8) & 0xF) as u8 + b'A' }

    #[inline]
    pub fn rack(&self) -> u8 { ((self.0 >> 4) & 0xF) as u8 + 1 }

    #[inline]
    pub fn shelf(&self) -> u8 { (self.0 & 0xF) as u8 + 1 }
}

impl fmt::Display for SlotId {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}{}.{}", self.aisle() as char, self.rack(), self.shelf())
    }
}

SlotId is a Copy newtype over u16: no heap, single-instruction hashing, and its natural integer ordering matches lexicographic slot ordering for free, because the aisle occupies the highest 4-bit field. The old representation was a four-field struct dragging a heap-allocated original_key: String through every pick, sort, and comparison, around 32 bytes plus an allocation, for data that fits in 2 bytes. The string form still exists where humans need it (Display reconstructs "G11.12" on demand at output boundaries), but it stopped being the working representation.

I considered a flat index (aisle * 192 + rack * 16 + shelf), which packs even denser. I kept the fixed 4-bit fields instead: decoding takes shifts and masks where the flat index takes a division and a modulo, and the hex value remains readable: 0x06AB is aisle 6, rack 10, shelf 11 at a glance. When a route dump misbehaves, that last property earns its keep. As a bonus, a SlotId crosses the WASM boundary as a bare JavaScript number, and the same 12-bit arithmetic decodes it on the TypeScript side, with no serialization format to disagree about.

Closing the Quality Gap

Storage work changed nothing about route quality. The mean gap sat at 11.53% through four stages because the routes themselves were still greedy. Always walking to the closest remaining pick tends to strand the operator in a far corner, with the door on the other side of the floor. The classic repair is 2-opt: take two legs of the route, reconnect them the other way around, keep the change if the total walk shrinks, and repeat until nothing improves. The textbook version compares only the forward path. Mine also counts the walk back to the door in every comparison. That one adjustment closed the mean gap to 0.00% on the exact-solvable pack. A final or-opt pass, which pulls a short run of one to three stops out of the route and reinserts it wherever the total shrinks, polishes the remaining scenarios for about 3 µs more per solve. On the weighted lens the story repeats: 7.03 ms at 8.83% mean gap for the MVP, 29.5 µs at 0.00% for the current stage, and the JPS stage recorded explicitly as unsupported.

Matching the exact solver with a fast heuristic is less magic than it sounds: at 10–30 picks per order these instances are small, and the exact solver exists precisely to say when small stops being easy. The useful sentence is concrete: on the exact-solvable pack, the mean gap rounds to 0.00% in microseconds.

The Wasm Boundary

The optimizer compiles to WebAssembly, and tsify generates the TypeScript types directly from the Rust structs, so the two sides cannot drift. The JavaScript wrapper around the module is about 18 lines. The boundary returns typed errors: malformed slot labels are rejected during deserialization, and labels that parse but reference racks missing from the floor plan surface as a catchable JS exception with a message naming the offending position, instead of a panic that aborts the WASM instance.


By the Numbers

MetricValue
EngagementSep 2019 – Feb 2022, sole engineer
RuntimeNode.js 16 in the last deployed version, with experimental Rust/WASM optimizer modules
Servershared Debian box, 4 GB RAM, air-gapped, no Docker (by customer request)
Floor plan17×31 grid (527 cells), 97 positions (96 racks + door)
Order sizeroughly 10–30 picks per solve
Solve time, MVP → current4.91 ms → 30.3 µs (~160×)
Mean gap vs exact optimum11.53% → 0.00% (uniform), 8.83% → 0.00% (weighted)
Heap allocations per solve~6,800 → 97
Distance lookup statea 717 KB warm HashMap replaced by one index into 36.8 KB compile-time tables
SlotIdaisle/rack/shelf packed into 12 bits of a u16
Exact solver validationbrute force over all 5,040 permutations of 8-node instances
Exact solver caphours, with timeout-prone scenarios quarantined from the headline average

What I’d Do Differently

Build the exact solver sooner. Validating against real orders and tracking solve times catches regressions, but it cannot say how much better a route could have been. The rough checks I had during the engagement caught regressions, but the exact solver and scenario packs came years later, and they were small enough to have been built upfront as optimizer infrastructure rather than polish. They sharpen every tuning decision that follows.

Design the error boundary before the happy path. The first WASM boundary was panic-based: malformed input aborted the entire instance, which in a long-lived server process is the worst possible failure mode. Typed errors at the boundary cost little. Panicking internals are fine. A panicking public surface is not.

Treat the compile-time-frozen layout as the scale trade-off it is. Baking the floor plan into the binary gives guardrails I love (an unreachable rack is a compile error) and lookups that cost one array index. But it means a layout change requires a recompile and a redeploy. For one warehouse with a stable floor plan and one engineer, that trade-off is right. For a product serving many warehouses it would be wrong, and the honest answer is that the architecture encodes the business context it was built in.