Skip to content
John Hodge

← Blog

Modeling Roasted Coffee as a Perishable Asset

Most perishable goods lose value from the moment they are made. Roasted coffee does not. It usually improves for days or weeks after roasting, reaches a peak, then slowly stales. The value curve is hump-shaped, and that one fact makes coffee a clean toy problem for a set of questions I find interesting: how to price a perishable asset, when storage is worth its cost, and how to ship a useful model as a small client-side tool.

I built a model for it, wrapped it in a freshness calculator on a coffee site I run, and open-sourced the engine as coffee-freshness-model. This post is the reasoning behind it. The math and assumptions live in the repo’s MODEL.md; here I want to walk through why the problem is shaped the way it is.

Why the curve is not monotonic

Fresh-roasted coffee holds a lot of carbon dioxide. Brew it too soon and the gas interferes with wetting and extraction, so a very young coffee tends to bloom violently and taste thin or sour. Resting for a few days lets the gas settle and the cup comes together. Working against that, the aromatic compounds that make coffee taste good oxidize over time. The Specialty Coffee Association’s literature review on staling identifies oxygen as the primary driver and reports that cutting oxygen in the package can extend shelf life by a large factor.

So quality is the product of two processes moving in opposite directions: a resting benefit that rises after roast, and a staling cost that grows once the coffee is past its prime. A model that treats freshness as simple decay from the roast date gets the early window backwards and discounts coffee that is about to get better. The right shape is a hump.

The peak location depends on the roast. Dark roasts peak within a few days. Medium specialty roasts often drink well from roughly one to three weeks. Dense light and Nordic roasts can keep improving past four to six weeks. Any model has to carry that variation as a parameter.

The model

I represent drink-now quality Q(a) at effective age a (in days) as a resting score times a staling score:

function restingScore(age: number, p: RoastProfile): number {
  return 1 / (1 + Math.exp(-(age - p.startDay) / p.ramp));
}

function stalingScore(age: number, p: RoastProfile): number {
  const lateDays = Math.max(age - p.peakDay, 0);
  return Math.exp(-p.staleLambda * lateDays);
}

function freshnessScore(age: number, p: RoastProfile): number {
  return restingScore(age, p) * stalingScore(age, p);
}

The resting score is a logistic rise toward 1, centered on startDay with slope set by ramp. The staling score holds at 1 until the peak, then decays exponentially at staleLambda per day. Their product is low when the coffee is too young, climbs through the rest period, sits near 1 at peak, and falls away after that. Two parameters control where it rises and how fast it falls, which is enough to separate a dark roast from a Nordic one.

Buyer value, not just today

A bag is consumed over many days, so a single snapshot of quality is the wrong thing to optimize. A bag three days off roast may be mediocre this morning yet a good buy, because it will rest into its peak on the shelf. The decision should use the average quality across the days you will actually brew it:

function buyerValue(age: number, p: RoastProfile, days: number, fwd = 1): number {
  let total = 0;
  for (let d = 0; d <= days; d++) total += freshnessScore(age + d * fwd, p);
  return total / (days + 1);
}

Using buyer value rather than drink-now quality stops the model from penalizing a slightly-too-fresh bag and from flattering a bag that tastes fine today but has no window left.

Storage as an effective-age clock

Storage changes how fast the clock runs. Sealed room temperature is the reference. An opened bag ages faster because oxygen gets in. A sealed frozen portion ages much more slowly. I fold this into an effective age:

a_eff = sealedDays + openedMult * openedDays + frozenMult * frozenDays

with openedMult around 2 and frozenMult around 0.05 as defaults. These are tunable assumptions rather than measured constants. There is a brewing-side reason to take freezing seriously beyond storage: Uman and coauthors found that colder beans grind into a narrower particle-size distribution (Scientific Reports 6:24483, 2016), which supports grinding single doses straight from frozen.

The priors are heuristic

Each roast style carries a prior for startDay, peakDay, goodThroughDay, ramp, and staleLambda. I set them from the SCA review and from a resting guide I wrote, calibrated by judgment. They are coarse buckets, not lab values, and the package versions them so a later recalibration is a clean before-and-after rather than a silent edit. The most important design rule in the whole project is to avoid false precision: the output is a hypothesis to confirm by tasting, and the tool says so.

The consumer decision

For a home drinker, the model turns the curve into a few concrete answers. It buckets each cup in the bag by the quality it will be drunk at, which makes the freezer decision tangible: if the last six cups land past peak, freezing part of the bag recovers them. It reports cost per good cup, which is a better value signal than price per bag. A cheap bag that goes stale halfway through can cost more per good cup than a smaller, pricier bag finished at peak.

The recommendation collapses to one of: wait, brew now, drink soon, freeze part of it, or buy a smaller bag next time. That last one matters because the cleanest fix for staling is owning less coffee at once.

The retail decision

The same model handles the shop side, where it becomes an inventory-pricing problem. This is well-trodden ground in operations research. Gallego and van Ryzin solved optimal dynamic pricing for a fixed stock over a finite horizon with stochastic demand (Management Science 40(8), 1994), which is exactly the structure of a lot of roasted bags with a closing premium window. On the empirical side, Sanders showed that dynamic markdowns cut waste in a grocery bread category by about 21 percent (Marketing Science 43(2), 2024), which is a reasonable analogue for roasted coffee retail.

I deliberately framed this as a freshness markdown with a bounded multiplier rather than open optimization:

function qualityMultiplier(value: number, d: PricingDefaults): number {
  const raw = 1 + d.alpha * (value - d.target);
  return Math.max(d.floor, Math.min(d.ceiling, raw));
}

The clamp keeps prices legible and stops the model from producing erratic numbers, which matters more for a premium brand than squeezing the last dollar from stale stock. The schedule maps lifecycle state to a posted label and price: full price while resting and at peak, a small discount in late peak, a larger one for batch-brew use past the window, and removal from premium retail after that.

The framing is a real constraint, not a cosmetic one. When Wendy’s floated dynamic pricing in 2024, the public read it as surge pricing and the company spent the following days clarifying that it would only lower prices in slow periods. A markdown advisor that only ever discounts aging stock avoids that failure mode by construction.

Freezing is an option, priced

For a roaster, freezing is an option with a cost, and it is worth exercising only when the preserved value beats what a freezer actually costs to run, from space and packaging to the labor of managing portions. The energy piece is small: ENERGY STAR rates a certified chest freezer at about 215 kWh and 30 dollars a year, an upright at about 395 kWh and 60 dollars (ENERGY STAR), and Seattle City Light’s residential rate sits around 14 to 16 cents per kWh (rate schedule). The energy is rarely the deciding factor; capital and handling are. For a home drinker who already owns a freezer, the calculus is simple, and saving one good bag usually wins. A shop should freeze only its rare, slow-moving, high-margin lots.

Why it runs in the browser

The calculator is deterministic math over a dozen inputs. It needs no database, no auth, and no server-side secrets, so v1 runs entirely client-side: a static page served from Cloudflare, instant recompute as inputs change, no data leaving the browser, and shareable state encoded in the URL. That keeps hosting cost near zero and the privacy story clean.

The engine is a pure, dependency-free TypeScript module. It takes the bag’s age as an input, so all date and clock logic stays in the caller, which makes it trivial to unit test. The same module powers the web calculator and ships as an npm package, so there is one source of truth for the math rather than a web copy drifting from a library copy. The repo has a small vitest suite that checks the curve is monotonic up to peak, that buyer value averages correctly, that the effective-age clock behaves, and that the markdown bands land at the right boundaries.

What the model does not know

It does not know a given bean’s chemistry, your water, your grinder, or your palate. The priors are coarse, and the storage multipliers are assumptions. The honest use is to treat the output as a starting hypothesis and let tasting correct it. The natural next step is calibration: with enough logged tasting outcomes, you can fit per-roaster or per-bean curves that beat the defaults, and the version field on the priors is there to make that a clean upgrade.

That is the part I find worth doing. Taking a fuzzy real-world system, writing down a small transparent model with its assumptions exposed, and shipping it as something people can use and check beats either a black box or a vibe. The coffee is incidental. The pattern is the point.

The engine is MIT-licensed at coffee-freshness-model, and the consumer tool is the BeanBench freshness calculator.