DEV Community

Elio Liberatore
Elio Liberatore

Posted on

Shipping a statistical model to the browser: Dixon–Coles soccer predictions with no backend and no API key

I wanted a public page where anyone could click a soccer match and see real probabilities behind it — 1X2, Over/Under, both teams to score, exact scorelines. The model already existed: a Dixon–Coles bivariate Poisson fitted on finished matches. The hard part was never the statistics. It was getting the thing onto a page that costs me nothing and exposes nothing.

Here's what I ended up with, and why the obvious approaches don't work.

Three options that all fail

Call the API from the browser. Simplest to write, and it puts my API token in everyone's DevTools. Dead on arrival.

Put a proxy in front of it. Now I'm running a server, and every visitor — including every bot — costs me a model run. A page that gets popular becomes a page that bills me for being popular. That's a bad shape for a free demo.

Pre-render everything to static HTML. Cheap and safe, but the whole point is interaction. I want you to flip the Over/Under line from 2.5 to 3.5 and see the number move. Pre-rendering every market for every line for every match is a combinatorial mess, and it's a lot of bytes to ship.

All three fail for the same reason: they treat the model's output as the thing to deliver.

The parameters are smaller than the answers

A Dixon–Coles model, once fitted, is almost nothing. Per match, it's two expected goal rates — λ for the home side, λ for the away side. Per league, it's one low-score correlation parameter, ρ. That's it. Every market on the page is arithmetic over those three numbers.

So don't ship the answers. Ship λh, λa, ρ, and let the browser do the arithmetic.

The payload looks like this:

{
  "generatedAt": "2026-09-15T15:06:00Z",
  "model": "Dixon-Coles bivariate Poisson, MLE-fitted attack/defence with exponential time decay (xi=0.0018), maxGoals=10",
  "leagues": [
    {
      "slug": "eng.1",
      "name": "Premier League",
      "rho": -0.115723,
      "historyMatches": 1141,
      "matches": [
        { "k": "2026-09-18T19:00:00.000Z", "h": "Brentford", "a": "Chelsea",
          "lh": 1.670193, "la": 1.560041, "q": 0 }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Six keys per match, one of which (q) is just a data-quality flag. A dozen leagues and a couple hundred upcoming fixtures fit in about 23 KB of JSON, inlined in the page. The whole document — markup, styles, logic, data — is around 54 KB. No fetch, no second request, no loading state.

The recompute is twenty lines

Dixon–Coles is independent Poisson with a correction applied to the four low-scoring cells — 0–0, 0–1, 1–0, 1–1 — where real football deviates from independence. The correction is the τ function, and ρ is its single parameter:

function grid(lh, la, rho) {
  var g = [], s = 0, x, y, p, tau;
  for (x = 0; x <= MAXG; x++) {
    g[x] = [];
    for (y = 0; y <= MAXG; y++) {
      p = pois(x, lh) * pois(y, la);
      if      (x === 0 && y === 0) tau = 1 - lh * la * rho;
      else if (x === 0 && y === 1) tau = 1 + lh * rho;
      else if (x === 1 && y === 0) tau = 1 + la * rho;
      else if (x === 1 && y === 1) tau = 1 - rho;
      else                         tau = 1;
      g[x][y] = p * tau;
      s += g[x][y];
    }
  }
  for (x = 0; x <= MAXG; x++)
    for (y = 0; y <= MAXG; y++) g[x][y] /= s;
  return g;
}
Enter fullscreen mode Exit fullscreen mode

An 11×11 grid of scorelines, renormalised so it sums to 1. Every market is then a sum over cells of that one grid:

function markets(g, line) {
  var p1 = 0, px = 0, p2 = 0, ov = 0, bt = 0, x, y, v;
  for (x = 0; x <= MAXG; x++)
    for (y = 0; y <= MAXG; y++) {
      v = g[x][y];
      if      (x > y)   p1 += v;
      else if (x === y) px += v;
      else              p2 += v;
      if (x + y > line) ov += v;
      if (x > 0 && y > 0) bt += v;
    }
  return { p1: p1, px: px, p2: p2, ov: ov, un: 1 - ov, bt: bt, nb: 1 - bt };
}
Enter fullscreen mode Exit fullscreen mode

This is the part I like most, and it's not a performance argument. Because every market comes out of the same grid, the page is internally consistent by construction. The 1X2 probabilities, the Over/Under for every line, both-teams-to-score, and the top scorelines can't contradict each other, because they're five different sums over one object. If you've ever assembled a page like this from separate endpoints, you know how easy it is to publish a "draw" probability that disagrees with the sum of the 0–0, 1–1 and 2–2 cells sitting right next to it.

Flipping the Over/Under line from 2.5 to 4.5 doesn't fetch anything. It changes one comparison in a loop over 121 cells.

Check your reimplementation. I ran the browser's grid against the scoreline grid the model itself returns, match by match. Largest disagreement: about 2e-7 — floating-point noise. Do this before you trust a client-side reimplementation of anything; "looks about right" is how you ship a subtly wrong model.

Keeping it fresh without a server

A build script calls the model once per league, writes the parameters into the page template, and commits the result. GitHub Actions runs it on a cron; Pages serves it. Hosting cost is zero, per-visitor cost is zero, and the only recurring cost is one model run per league per day.

Three things I got wrong first, so you don't have to:

The template lives inside the build script. I edited the published HTML directly to fix something, felt good about it, and the next morning's run overwrote my fix. If a generator owns a file, the file is not the source. Obvious in hindsight; still cost me an afternoon.

Fail loudly, publish conservatively. If one league returns no fixtures, the script skips it. If every league comes back empty, it exits non-zero and publishes nothing — so a bad upstream day leaves yesterday's good numbers up instead of replacing them with a blank page. The failure mode you want is stale, not empty.

Scheduled doesn't mean punctual. My cron says 11:30 UTC. Actual runs have landed three and a half and five and a half hours late. GitHub's scheduled workflows queue on shared capacity and there is no guarantee attached to that timestamp. If your copy says "updated every morning", your copy is wrong. Put the generation timestamp on the page and let it speak for itself.

Say when the model is thin

Early in a European season, a promoted side has three or four matches of top-flight history. The fit still returns a number, and the number is confidently silly.

So the payload carries a per-match quality flag, and the page renders a LOW DATA badge plus a plain-language card explaining that one of these teams has very little history behind its rate. It costs one flag per match in the payload and it's the difference between a page that reports numbers and a page that reports numbers honestly.

Every model has a region where it shouldn't be trusted. Most interfaces hide it. Showing it costs almost nothing and is the single change most likely to make a technical reader believe the rest of your output.

What it adds up to

  • Model parameters, not model answers, on the wire
  • ~23 KB of JSON, one document, no runtime fetches
  • No credentials in the client, because the client never calls anything
  • Internally consistent markets, because they're sums over one grid
  • Static hosting; cost doesn't scale with traffic
  • Honest about staleness and about thin data

The technique generalises past football. Any fitted model whose parameters are small compared to its output surface can be shipped this way — pricing curves, survival models, anything where a handful of coefficients regenerate a large interactive result set. Ask what the smallest object is that lets the client rebuild the answer, and send that instead.

Page: commodus67.github.io/soccer-predictions-demo

Model behind it: soccer-dixon-coles-match-predictor

Simulation and data, not tips. Nothing here is betting advice.

Top comments (0)