A split table for a race looks like plain division. Take the goal time, divide it by the distance, and print one row per checkpoint. Runners read those rows off a paper pace band or a watch in the middle of a race, so every row has to be a whole number of seconds. Rounding is where the plain version goes wrong.
Rounding each segment
Take a 4:00:00 marathon. The distance is 42.195 km, so with a checkpoint every kilometre you get 42 full kilometres and a last segment of 0.195 km. The obvious code works out each segment's time and rounds it:
const total = 4 * 3600; // 14,400 s const distance = 42.195; // km const points = [...Array(42)].map((_, i) => i + 1).concat(distance); let previous = 0; const segments = points.map((point) => { const seconds = Math.round((total * (point - previous)) / distance); previous = point; return seconds; }); segments.reduce((a, b) => a + b, 0); // 14,389 A full kilometre at that pace takes 341.27 seconds, which rounds down to 5:41. Lose 0.27 seconds 42 times and the table for a 4:00:00 race ends at 3:59:49, 11 seconds short.
The error can go either way. At a 3:30:00 goal the same code rounds every kilometre up and the table ends at 3:30:16. Fewer checkpoints means less drift, yet the 4-hour table still ends at 3:59:54 with mile checkpoints and at 3:59:57 with one every 5 km.
The elapsed column is worse than the total. It is the number a runner compares with the race clock, and with kilometre splits it drifts up to about 11.5 seconds away from the exact plan before the last segment.
Round the elapsed time, then subtract
The fix is to round the running total instead of each piece. Work out the exact elapsed time at each checkpoint, round that, and make each segment the difference between two rounded elapsed times.
function allocate(weights: number[], total: number) { const sum = weights.reduce((a, b) => a + b, 0); const segments: number[] = []; let cumulative = 0; let previous = 0; weights.forEach((weight, i) => { cumulative += weight; const remaining = weights.length - 1 - i; let elapsed = i === weights.length - 1 ? total : Math.round((total * cumulative) / sum); // keep every segment at least 1 s long elapsed = Math.min(Math.max(elapsed, previous + 1), total - remaining); segments.push(elapsed - previous); previous = elapsed; }); return segments; } The weights here are segment lengths in kilometres. Every elapsed time lands within half a second of the exact plan. The last one is set to the goal itself, so the table always finishes on the goal time.
The segments now alternate. For the 4:00:00 marathon with kilometre splits, each kilometre is 5:41 or 5:42. With a checkpoint every 5 km, the segments are 0:28:26 or 0:28:27, the final 2.195 km takes 0:12:29, and the 9 rows add up to exactly 4:00:00.
The clamp line covers a corner case. A goal of a few seconds over many checkpoints could round two elapsed times to the same second, which would print a 0-second segment. The clamp gives every segment at least one second, and the calculator rejects a goal shorter than the number of checkpoints before it gets here.
Keep the last partial segment
A marathon is 26.2188 miles, and some calculators round it to 26.2. At 4:00:00 that's 9:09.6 per mile, which shows as 9:10. The true figure is 9:09.2, which shows as 9:09. When I checked the top 5 US and UK results for "marathon split calculator" on 24 September 2026, two of them left out the final 0.2188 miles altogether and one used 26.2 miles.
On an even 4:00:00 plan, mile 26 comes at 3:58:00, so the final 0.2188 miles takes two minutes. The checkpoint list has to end on the real distance:
const MILE_KM = 1.609344; // exact, by definition function checkpoints(distanceKm: number, stepKm: number) { const points: number[] = []; for (let i = 1; i * stepKm < distanceKm - 1e-9; i++) points.push(i * stepKm); points.push(distanceKm); return points; } The loop stops before the finish and then adds the exact distance, so the last segment is whatever is left over: 0.195 km, 2.195 km or 0.2188 miles, depending on the spacing. The small tolerance stops float noise from adding a second checkpoint on top of the finish.
Pacing shapes reuse the same rounding
Because allocate takes weights rather than distances, pacing shapes need very little extra code. For a negative split, the pace multiplier runs in a straight line from 1.02 at the start to 0.98 at the finish, and each segment's weight is its length times the multiplier at its midpoint:
const SLOPE = 0.02; const multiplier = (fraction: number) => 1 + SLOPE * (1 - 2 * fraction); let previous = 0; const weights = points.map((point) => { const midpoint = (previous + point) / 2; const weight = (point - previous) * multiplier(midpoint / distance); previous = point; return weight; }); The value of a straight line at the midpoint of a segment times its length is the exact integral over that segment, so the shape doesn't depend on where the checkpoints fall. Split a 4:00:00 marathon into halves and the negative plan gives 2:01:12 and 1:58:48, a 50.5% and 49.5% split. A positive split swaps the two. Every plan still finishes on 4:00:00, because it is the same allocation underneath.
The printed pace band on the site takes its rows from the same plan. If you want to see the output, the marathon split calculator on Race Pace Planner shows the even, negative and positive tables side by side for any goal time, with a Segment column and an Elapsed column built this way.