DEV Community

Carson Xie
Carson Xie

Posted on Fully Autonomous

Building a freight CBM and chargeable-weight calculator: the edge cases nobody warns you about

I work at a freight forwarder in Shenzhen, and most of the questions we get before a quote are some version of "why is my shipment billed on more kilos than it weighs?". So we built a calculator: enter your cartons, get the volume, the volumetric weight, and how each of our shipping lanes would actually bill them.

The arithmetic looked like a one-liner. It wasn't. This post walks through the model we ended up with in TypeScript, and the edge cases that broke the first versions.

The three numbers everything starts from

Every carton has outside dimensions in centimetres, a count and a gross weight. From those you get:

  • Volume (CBM): L × W × H ÷ 1,000,000, times the number of cartons.
  • Volumetric weight: L × W × H ÷ divisor. Airlines sell space as well as weight, so a light, bulky carton is billed as if it weighed more. The usual air divisor is 6000; many couriers use 5000, which makes the same carton 20% "heavier".
  • Chargeable weight: the larger of gross weight and volumetric weight.

At ÷6000, one cubic metre "weighs" about 167 kg. Cargo lighter than that per CBM is billed on volume, heavier cargo on weight.

The core function is plain and has no framework code in it, so the page, the interactive component and a Node test script all import the same module:

export const AIR_DIVISOR = 6000
export const COURIER_DIVISOR = 5000
/** 1 sai (才) is one cubic shaku, (10/33 m)³, so 1 m³ = 35.937 sai. */
export const SAI_PER_CBM = 35.937

export type Carton = {
  length: number // cm, outside dimensions
  width: number
  height: number
  quantity: number
  weight: number // kg per carton, 0 when not entered
}

export function totals(cartons: Carton[]) {
  let count = 0, volume = 0, weight = 0, missingWeight = false
  for (const c of cartons) {
    count += c.quantity
    volume += c.length * c.width * c.height * c.quantity
    weight += c.weight * c.quantity
    if (!c.weight) missingWeight = true
  }
  const cbm = volume / 1_000_000
  const volumetric = volume / AIR_DIVISOR
  return {
    cartons: count,
    cbm,
    weight,
    sai: cbm * SAI_PER_CBM,
    volumetric,
    volumetric5000: volume / COURIER_DIVISOR,
    chargeable: Math.max(weight, volumetric),
    density: weight && cbm ? weight / cbm : null,
    missingWeight,
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here. Volume is summed in cm³ and divided once at the end, rather than adding up rounded per-carton CBM values. And weight is optional: plenty of people only want the volume, so a missing weight is flagged (missingWeight) instead of being treated as an error.

The sai (才) line is there because Japanese warehouses and truckers quote in sai, a traditional volume unit. If you ship to Japan, the domestic delivery quote won't match your CBM figure until you convert it.

A worked example

The calculator's example data is a typical mixed shipment from one supplier:

  • 10 cartons of 60 × 40 × 40 cm, 12 kg each
  • 3 cartons of 45 × 35 × 30 cm, 8 kg each

totals() returns:

Field Value
CBM 1.10175
Gross weight 144 kg
Volumetric weight ÷6000 183.625 kg
Volumetric weight ÷5000 220.35 kg
Sai 39.59
Density ~131 kg/CBM

So by air this 144 kg shipment is billed as 183.6 kg. That one line already explains most "why is my invoice heavier" emails. The interesting part is what happens next.

Lanes are data, not code

A single "chargeable weight" figure turned out to be useless, because every lane bills differently. Some bill per kg, some per CBM. Some only count part of the volumetric excess. Some have minimums per shipment, others per carton. We ended up describing each lane as data and writing one billing function per unit:

type WeightLane = {
  unit: "kg"
  divisor: number            // 6000 or 5000
  volumetricShare?: number   // 0.5 = only half of the volumetric excess is billed
  minimum?: number           // least billed per shipment, kg
  cartonMinimum?: number     // each carton counts as at least this many kg
  parcel?: { step: number; max: number } // each carton is its own parcel
}

type VolumeLane = {
  unit: "cbm"
  kgPerCbm?: number // heavy cargo: this many kg count as 1 CBM
  minimum: number   // least billed per shipment, CBM
  step?: number     // billed CBM rounded up to this step
}
Enter fullscreen mode Exit fullscreen mode

Each lane entry in our code carries a comment naming the sheet and cells of the rate card it came from. The calculator only shows billing rules, never prices, so the data stays valid when the rates change. When someone asks "why does it say 21 kg?", the answer is one comment away.

Weight or measure (W/M)

Less-than-container sea freight and cross-border trucking are often billed per CBM, but with a weight ceiling per CBM. Heavier cargo is converted into "billable CBM":

const byWeight = lane.kgPerCbm ? sum.weight / lane.kgPerCbm : 0
let billed = Math.max(sum.cbm, byWeight)
let basis = byWeight > sum.cbm ? "weight" : "volume"
if (billed < lane.minimum) {
  billed = lane.minimum
  basis = "minimum"
}
if (lane.step) billed = ceilTo(billed, lane.step)
Enter fullscreen mode Exit fullscreen mode

The ratio is different on every lane. On ours, 1 CBM counts as 500 kg by sea to Southeast Asia and Africa, 400 kg to Indonesia, 363 kg by sea plus truck to the UK, 350 kg by land to Thailand and 230 kg by land to Vietnam. Twenty 40 × 30 × 30 cm cartons of 25 kg each measure 0.72 CBM, but on the Vietnam land lane they bill as 500 ÷ 230 ≈ 2.17 CBM, three times the measured volume.

Per-carton minimums change the order of operations

Several of our Europe lanes count every carton as at least 12 kg, and the standard air lanes there also bill only half of the volumetric excess ("split" volumetric weight). The naive approach takes the shipment total and then applies the minimum. That's wrong: the floor applies per carton, before the comparison with volume.

// A carton lighter than the per-carton minimum counts at the minimum,
// so a 1 kg and a 100 kg carton weigh 110 kg, not 101 kg.
const min = lane.cartonMinimum ?? 0
const weight = cartons.reduce(
  (kg, c) => kg + Math.max(c.weight, min) * c.quantity, 0)

// Split: past gross weight, only a share of the volumetric weight counts.
const share = lane.volumetricShare ?? 1
const byVolume = sum.weight + Math.max(volumetric - sum.weight, 0) * share
const billed = Math.max(weight, byVolume)
Enter fullscreen mode Exit fullscreen mode

For the example shipment on the Europe air lane, the per-carton floor gives 156 kg (the three 8 kg cartons count as 12 kg each). The split rule gives 144 + (183.625 − 144) × 50% = 163.8 kg. The higher one wins: 163.8 kg. On the Europe truck and rail lanes, which don't split, the same cartons bill 183.6 kg. Same boxes, two lanes, two different answers, both correct.

Parcels are billed carton by carton

Our small-parcel lane to Japan bills each carton as its own parcel: the larger of gross and volumetric weight, rounded up in 0.5 kg steps. You can't compute that from totals. The 60 × 40 × 40 cartons are 16 kg each (volumetric beats 12 kg gross) and the small ones 8 kg (gross beats 7.875 kg volumetric), so the shipment bills 10 × 16 + 3 × 8 = 184 kg. That's not the 183.625 kg you'd get from totals.

The floating-point bug that rounds 0.3 CBM up to 0.4

Our Philippines sea lane rounds billed volume up to 0.1 CBM steps. The first ceilTo was the obvious one:

const ceilTo = (value: number, step: number) =>
  Math.ceil(value / step) * step
Enter fullscreen mode Exit fullscreen mode

Then I fed it 0.1 * 3, which in JavaScript is 0.30000000000000004. Divided by 0.1 that's 3.0000000000000004, Math.ceil makes it 4, and a value that is already on a step comes back as 0.4 CBM instead of 0.3. Multiplying back adds noise of its own: our 0.625 CBM test shipment comes back as 0.7000000000000001, not 0.7, which then shows up as "0.7000…" anywhere you forget to format.

The fix subtracts a tiny epsilon before rounding up, then rounds the result to a sane precision:

/** Rounds up to a step, ignoring float noise such as 0.30000000000000004. */
function ceilTo(value: number, step: number) {
  return Math.round(Math.ceil(value / step - 1e-9) * step * 1e6) / 1e6
}
Enter fullscreen mode Exit fullscreen mode

1e-9 is far below anything a tape measure can produce, so it only swallows floating-point noise and never a real 0.0001 CBM. The tests pin the expected results down: five 50 × 50 × 50 cm cartons are 0.625 CBM and must bill exactly 0.7, and the comparisons use toBeCloseTo instead of toBe wherever a sum of decimals is involved.

Limits: hard ones and surcharge ones

A carton can also be too big or too heavy for a lane. Couriers measure this in different ways (longest side, sum of sides, "girth" = longest side + 2 × the other two), and most have two thresholds. Past the first you pay a surcharge; past the second the carton isn't accepted at all. On our Europe lanes, where UPS delivers most cartons, a carton over 270 cm, 70 kg or 400 cm girth is refused, and one over 100 cm, a 76 cm second side, 24 kg or 300 cm girth pays a surcharge.

Each limit is a key plus a measuring function, so adding a courier is data again:

const girth = (c: Carton) =>
  2 * (c.length + c.width + c.height) - Math.max(c.length, c.width, c.height)

const measures: [keyof LaneLimits, (c: Carton) => number][] = [
  ["longest", longestSide],
  ["girth", girth],
  ["weight", (c) => c.weight],
  ["oversizeSide", longestSide],
  ["oversizeGirth", girth],
  // ...
]
Enter fullscreen mode Exit fullscreen mode

One subtle rule: if a carton breaks a hard limit, don't also warn about the matching surcharge. "This carton is too long" followed by "this carton costs extra because it's long" just confuses people. A small hardLimits map links each surcharge limit to its hard limit and filters the duplicates out.

Input is messier than the maths

In the end, more of the code deals with input than with arithmetic:

  • Decimal commas. The site runs in six languages. In German and Vietnamese, 12,5 means 12.5. We detect it from the locale itself instead of a hard-coded list:
  const usesDecimalComma = (locale: string) =>
    new Intl.NumberFormat(locale).formatToParts(0.5)
      .some((p) => p.type === "decimal" && p.value === ",")
Enter fullscreen mode Exit fullscreen mode
  • Full-width digits. Japanese and Chinese IMEs often type 60 or 12.5. raw.normalize("NFKC") turns them into ASCII before parsing.
  • Millimetres typed as centimetres. A 60 × 40 × 40 cm carton typed as "600 × 400 × 400" is 96 CBM, a thousand times the real 0.096. Upper bounds (1,500 cm per side, 30,000 kg per carton) reject the bigger slips, like a 1.6 m pallet side entered as 1600, without blocking real oversized cargo. Smaller ones still get through, but show up as an absurd volume.
  • Missing weight. It's allowed, but every lane that needs weight says so instead of silently billing 0 kg.

What I'd tell anyone building one

  1. Keep the domain logic pure and framework-free. Ours is one TypeScript module with no React, shared by the page, the UI and the tests.
  2. Model lanes, couriers and rate cards as data, with a pointer to the source document. The rules change more often than the code.
  3. Return why a number was chosen (basis: "volumetric" | "split" | "cartonMinimum" | "minimum" | …), not just the number. Users trust "billed on volumetric weight ÷6000" far more than a bare 183.6.
  4. Test the rounding with values that are ugly in binary, like 0.1 * 3.

If you want to poke at the edge cases above, the live calculator has a button that fills in the example shipment, and it shows how each lane bills it.


Carson Xie works at Shenma Fastship, a freight forwarder in Shenzhen that ships by sea, air, rail and road from China to Japan, Southeast Asia, Africa, Europe and the US.

Top comments (0)