Aleph Front

Insights / Technical

Price lists, the curse of B2B platforms

Every B2B buyer has their own price, and every commerce platform has to pick a poison to serve it. We built the three strategies the market uses and measured them, because nobody publishes these numbers.

In B2C, a product has a price. In B2B, the same drill costs 142.00 for one customer, 127.75 for a second and 118.40 for a third. Showing the right price, to the right buyer, on every page, is the quiet curse of B2B commerce.

Every platform on the market has picked a way to deal with it, and each one hides a cost. Nobody publishes what those costs are, so we measured them.

Why it is hard

Each customer’s price comes from a stack of lists. Their own contract comes first, then their customer group, then a base list that prices everything. The first list with a price for that product wins.

Now multiply. A listing page asks that question 48 times. Sorting a category by price asks it thousands of times. And the answer has to stay right while prices and contracts keep changing.

There are three common ways to do it. We built each one in its most favourable form and ran them on the same data, at 20,000 products and 2,000 customers, all on contract, and on larger sets where noted. Real platforms add queues, layers and network hops, so treat our figures as lower bounds. These are our own prototypes, not any vendor’s product. The details are at the end of this post.

Precompute: prepare every price in advance

Think of a printed price book for each customer. Customers who share the same lists can share a book, and a customer with their own contract gets their own.

  1. Before anyone visits, a price book is written for each customer.
  2. A buyer opens their own book. The price is already there, so it is instant.
  3. Prices drop 3% for everyone, so every book is rebuilt. With 2,000 customers, that took our test 44 s.
Writingbooks44 sto rebuild all 2,000 books-3% for everyoneOne book per customerBuyer142.00, instant142.00 → 137.74

The buyer gets the fastest answer of all: 0.015 ms to sort a category by their price. The bill arrives elsewhere, because every book holds a price for every product.

Prices stored in advanceSynthetic data, prototypes, best case for each strategy.

Precompute

Small changes are fine: 67 ms for a new contract customer. The problem is the change that touches every book. A 3% change on the base list, with only 2,000 customers, means rebuilding about 2,000 books, which takes 44 seconds.

That is why these systems plan ahead. One platform ships with a job that runs every 5 minutes and starts rebuilding 12 hours before a scheduled price change. Its documentation warns that recalculation can take “multiple hours for several hundred thousand product prices”. On another platform with a similar design, a public bug report describes 30 million prices taking 4 hours, and multiple days after a regression, according to its author.

Request time: work it out when the buyer asks

Nothing is prepared. Each time a buyer opens a page, the platform checks the lists in order, then keeps the answer in a cache for next time.

  1. Nothing is prepared. When a buyer asks, the lists are checked in order until one has a price.
  2. The answer goes into a cache, so the next page comes straight from there.
  3. A price changes to 114.85. Until a scheduled job clears the cache, the buyer still sees 118.40.
LookupBuyer118.40old price1. Contractno price2. Group118.40114.853. Basenot reachedCache118.40Scheduledjob

Recording a change is quick, 89 ms for a whole group list, because the real work has not happened yet. Buyers keep seeing the old price until the scheduled job runs, and the first page after that pays the full price again.

Sorting a category of 4,000 products by the buyer's priceMedian, warm, log scale, 20,000 products × 2,000 customers, all on contract. Synthetic data, prototypes, best case for each strategy.

PrecomputeRequest timeSearch index

A few milliseconds look harmless, but this is the floor, with nothing else on the page. A cache that covers everyone is also large: 40 million entries at 20,000 products and 2,000 customers, 500 million at 50,000 products and 10,000 customers.

There is one more catch. The search engine only knows the public price, so when a logged-in buyer sorts search results by price, they get the order of the public price, not their own.

Search index: put every price in the search engine

This fixes that catch. Every product in the search engine carries one price per customer or contract, so search can sort on the buyer’s own price.

  1. In the search engine, each product stores one price per customer: 2,000 prices per product here.
  2. A search sorts straight on your column, so results follow your own price.
  3. A new customer signs. All 20,000 products are rewritten to add one column: 20 s.
ProductPublicBuyer ABuyer BYou…New customerCordless drill142.00131.20127.75118.40…125.90Claw hammer18.5017.2017.9016.30…16.90Screws, box24.9023.1022.4021.60…22.80Hand saw31.0029.4028.1027.20…27.90Your searchsorted by your priceThe search engine's default limitis 1,000 fields per index.

The search engine pays for it. Its index grows 540 times, from 0.8 MiB to 415 MiB, with 20,000 products and 2,000 customers.

Search engines are not built for this. The one we used refuses more than 1,000 fields per index by default, to prevent what its makers call a “mapping explosion”. We had to raise that limit. In an earlier single-pass run at 50,000 products, on Elasticsearch 8.19.3, adding one customer crashed it with 2 GB of memory, and took 52 seconds with 4 GB.

Others have hit the same wall. One platform considers only the first 100 price rules by default, and sorting can be wrong beyond that. A hosted search service needs an extra copy of the index per customer group for each price sort. Another platform has to move prices to a separate index past 1,000 contracts.

Side by side

How long a price change takesLog scale, 20,000 products × 2,000 customers, all on contract. Precompute and Search index: until every buyer gets the new price. Request time records the change at once, then serves the old price until its scheduled job runs, which was not measured. ANOM: until the change is fully absorbed, median of 3 runs; it serves the new price earlier, once the change is saved. Synthetic data, best case for the prototypes.

PrecomputeRequest timeSearch indexANOM

Each of the three strategies picks which operation will hurt, then moves the pain somewhere a demo does not look. That is the curse. ANOM, in blue, is the subject of the next section.

And ANOM?

We ran the same scenarios, with the same data and the same prices, on ANOM, on the same server. Unlike the prototypes, ANOM ran with its full production data model and all its constraints, and we checked every price it returned.

The first question is the one buyers feel: after a price change, how long do they keep seeing the old price?

How long buyers keep seeing the old priceLog scale, after a 3% change on every product, 20,000 products × 2,000 customers, all on contract. ANOM: the time to save the change, after which it serves the new price. Request time's refresh interval was not measured. Synthetic data, best case for the prototypes.

PrecomputeRequest timeSearch indexANOM

Smaller changes shrink the window without closing it: 522 ms on Precompute and 739 ms on Search index when one group’s list changes, 67 ms and 20 s when a new customer signs a contract. These are best cases, because our rebuild starts the instant the change lands. Real platforms run it from scheduled jobs or queues, so the real window is that delay plus these figures.

ANOM serves the new price from the moment the change is saved, with no scheduled job to wait for. While it finishes absorbing the change, prices stay correct and pages take longer to serve.

The times below measure how long ANOM takes to fully absorb the change.

A 3% change on every productTime to fully absorb the change. ANOM: median of 3 runs. Request time keeps serving the old price until its scheduled job runs, which was not measured. Request time and Search index are shown only where they ran in this series. Synthetic data, best case for the prototypes.

PrecomputeRequest timeSearch indexANOM

With 2,000 customers, ANOM absorbs it in 3.1 seconds, about 14 times less than Precompute and 4 times less than Search index. Request time records the change faster, in 231 ms, but keeps serving the old price until its job runs. With 50,000 products and 10,000 customers, ANOM takes 2.8 seconds, about 35 times less than Precompute’s 99 s.

Time to prepare everything from scratchSame scale throughout. Search index is shown only where it ran in this series. Synthetic data, best case for the prototypes.

PrecomputeSearch indexANOM

Extra storage, on top of the price listsSame scale throughout. Search index is shown only where it ran in this series. Request time stores nothing extra apart from its cache. Synthetic data, best case for the prototypes.

PrecomputeSearch indexANOM

With 50,000 products and 10,000 customers, ANOM is ready in 1.5 seconds, against 93 s for Precompute, and needs 130 MiB against 3.2 GiB. With 20,000 products and 10,000 customers, all on contract, ANOM is ready in 12 seconds and needs 1,067 MiB.

Not every line goes ANOM’s way. A price already stored or cached reads faster, and a book written in advance absorbs a small change faster: 77 ms for a new contract customer at 50,000 products, against 260 ms. That is what those strategies were built for. ANOM leads where their bill comes due: preparing, storing, and changing prices in bulk without leaving buyers on the old ones.

Many buyers at once

So far, one buyer at a time. On a busy morning, hundreds of buyers price their baskets at the same moment. We sent baskets of 10 products, from 1 up to 10,000 buyers at once, at 20,000 products and 2,000 customers, all on contract. The search index ran on Elasticsearch, in two releases: 8.17.0, and 9.5.6, the latest at the time of testing.

Baskets priced per second, as buyers pile upLog scale, baskets of 10 products, 20,000 products × 2,000 customers, all on contract. Request time was not part of this test. Synthetic data.

ANOMPrecomputeElasticsearch 9.5.6Elasticsearch 8.17.0Collapse

At 10,000 buyers, Elasticsearch 8.17.0 turned away 33,878 of 34,515 requests and priced 127 baskets per second. Elasticsearch 9.5.6 lost 21,617 connections and priced 251.

From 10 buyers upward, ANOM prices more than 1.1 million baskets per second, and still 1.2 million with 10,000 buyers at once. Precompute levels off at about 310,000. The search index tops out below 4,000, then gives way at 10,000 buyers.

Time to price one basket, as buyers pile upMedian, log scale, same test.

ANOMPrecomputeElasticsearch 9.5.6Elasticsearch 8.17.0Collapse

The median of Elasticsearch 8.17.0 falls at 10,000 buyers because it turned away almost every request. One buyer in a hundred waited 3.3 seconds or more on it, and 4.8 seconds or more on 9.5.6.

ANOM answers each basket in 8 µs at the median, whether 10 or 10,000 buyers are waiting. On the search index, the median climbs from 2 ms with one buyer to 263 ms on 8.17.0 and 291 ms on 9.5.6 with 1,000, and Elasticsearch 9.5.6 reaches 535 ms at 10,000. At 10,000 buyers, Elasticsearch was using about 4 of its 6 cores and 4.6 GB of memory.

ANOM and Precompute were called directly, while the search index can only be reached over HTTP, from a container with 6 of the server’s 12 cores.

The podiums

All four ran together at 20,000 products and 2,000 customers, all on contract. Here is the podium for each test. Request time is not ranked, because its cache keeps serving the old (stale) price until its scheduled job runs.

One price changes
213Precompute3.6 msANOM2.0 msSearch index40 ms
A 3% change on every product
213Search index13 sANOM3.1 sPrecompute44 s
Time to prepare everything from scratch
213Search index18 sANOM2.1 sPrecompute44 s
Extra storage, on top of the price lists
213Search index415 MiBANOM191 MiBPrecompute1.2 GiB
A page of 48 prices
113Precompute35 µsANOM35 µsSearch index8.9 ms

Both take 35 µs at 50,000 products and 10,000 customers. At 20,000 and 2,000, Precompute takes 33 µs and ANOM 41 µs.

Baskets priced per second, 10,000 buyers at once
213Precompute311 k/sANOM1.2 M/sSearch index251/s

Search index: Elasticsearch 9.5.6. Elasticsearch 8.17.0 priced 127 per second.

One group's prices change
213ANOM692 msPrecompute522 msSearch index739 ms
A new customer signs a contract
213ANOM496 msPrecompute67 msSearch index20 s

Four questions for any platform

  • Is a new price served as soon as the change is made, or after a job, a rebuild or a cache expiry?
  • What does a 3% change on everything cost, with your number of customers?
  • Do search and sort use the buyer’s own price, or the public one?
  • Does storage multiply with every contract you sign?

Ask for the numbers, on your catalogue and your customers. As we found, nobody publishes them. Now you have ours.

How we tested

  • Data. Synthetic. A customer can have a contract list, belongs to one of 50 group lists covering 20% of the catalogue, and sees a base list covering everything. Typical scenarios: one customer in five has a contract, covering 2% of the catalogue. Worst case: every customer does, covering 10%.
  • Requests. Listing pages of 48 products, on categories of 4% of the catalogue (typical) or 20% (worst case). Reads are warm: after a warm-up pass, each request is played 3 times and the best kept, then the median is reported. Writes are the median of 3.
  • Load. 2,000 different baskets of 10 products, one warm-up pass, then 5 seconds at each step: 1, 10, 100, 1,000 and 10,000 buyers at once.
  • Price change. The general change we timed was a +3% on the base list. A -3% changes just as many prices. On Precompute, we count it as a full rebuild of all the books, the most favourable way to do it.
  • Machine. One Apple M4 Pro server, 12 cores, 24 GB. The search engine is Elasticsearch on a single node, in a container with 6 CPUs and 8 GB. Build, storage, reads and writes ran on 8.17.0 with 4 GB of memory, on default settings except the field limit. The load test ran on 8.17.0 and 9.5.6 with their default memory. Only its own time is counted.

Tell us what you sell and who buys it.