DEV Community

Cover image for I Rebuilt the One-Rep Max Calculator Every Gym Bro Uses — Here's the TypeScript Math Behind the Core Strength Formulas
G S
G S

Posted on

I Rebuilt the One-Rep Max Calculator Every Gym Bro Uses — Here's the TypeScript Math Behind the Core Strength Formulas

The Tab You Close Before Anyone Sees It

You just finished a heavy set. Phone's already in your hand, chalk still on your fingers, and you search "1 rep max calculator" to see where you actually stand.

What loads:

  • A "Your 1RM Is Loading..." spinner sitting on top of an ad that hasn't rendered yet, so the page jumps the second it does
  • A pop-under that opens a second tab you now have to close mid-set
  • Three different "Which Pre-Workout Fits YOUR Body Type?" quizzes wedged between you and the calculator
  • A results box that recalculates on a 500ms debounce, so typing "225" shows you the max for "2," then "22," then finally "225" — a full half-second later
  • Somewhere below the fold, in 8px gray text, the actual Epley formula, buried under four paragraphs of AI-generated filler about "the science of gains" You wanted three numbers: your estimated max, your training percentages, maybe a sanity check against the Brzycki formula. Instead you got an ad experience with a calculator bolted onto the side of it as an afterthought.

So — same as the Stair Calculator before it — I built the version I actually wanted to use: HypeCalc's 1-Rep Max Calculator. No debounce lag, no pop-unders, no spinner theater. You type a weight and a rep count, and three validated formulas resolve before you finish the keystroke.

This post is the engineering breakdown: the actual exercise-science math behind Epley, Brzycki, and Lander, the typed calculation core, and the memoized client component that makes it instant.


The Math: Why Three Formulas, Not One

A true 1RM test — grinding out a single, maximal-effort rep — is genuinely risky. Bar speed slows unpredictably near failure, form breaks down under fatigue, and the joint/connective-tissue stress is disproportionate to the training benefit. So instead, sports science estimates your 1RM from a sub-maximal set (a heavier-than-usual set you can still complete with good form), using regression equations built from real lifter data.

I implemented three of the most-cited formulas so a user can cross-check estimates rather than trust a single number blindly:

1. Epley Formula

The standard for compound lower-body lifts (squat, deadlift), reliable in the 3–8 rep range:

1RM = Weight × (1 + Reps / 30)
Enter fullscreen mode Exit fullscreen mode

2. Brzycki Formula

Preferred for upper-body pressing movements like bench press, where strength drops off faster as reps climb:

1RM = Weight / (1.0278 − 0.0278 × Reps)
Enter fullscreen mode Exit fullscreen mode

3. Lander Formula

A third validation point that tends to sit between the other two, useful for flagging when Epley or Brzycki is producing an outlier estimate:

1RM = (100 × Weight) / (101.3 − 2.67123 × Reps)
Enter fullscreen mode Exit fullscreen mode

Why This Matters More Than It Looks Like It Does

At low rep counts (3–6), these three formulas typically agree within a few pounds. But push the input to 12+ reps and they diverge hard — because past roughly 10 reps, you're no longer measuring peak force output, you're measuring muscular endurance, and the linear-regression assumptions the formulas rely on stop holding. A calculator that silently reports one formula's output as the answer is quietly lying to you at high rep counts. Showing all three, side by side, turns that failure mode into a visible signal instead of a hidden one.


The TypeScript Core: Three Formulas, One Pure Function

Same architectural rule as always: the math never touches JSX. It's a standalone, synchronous function that takes numbers and returns numbers — trivially unit-testable, and reusable anywhere (a future CLI, a training-log export, a mobile app) without dragging React along with it.

// lib/calculateOneRepMax.ts

export interface OneRepMaxInput {
  weight: number; // lbs or kg — unit-agnostic, output matches input unit
  reps: number;   // completed reps in the working set
}

export interface OneRepMaxResult {
  epley: number;
  brzycki: number;
  lander: number;
  average: number;
  isReliable: boolean; // false when reps push past the accurate estimation range
  percentages: { percent: number; weight: number }[];
}

const RELIABLE_REP_CEILING = 10;

export function calculateOneRepMax({
  weight,
  reps,
}: OneRepMaxInput): OneRepMaxResult {
  if (weight <= 0 || reps <= 0) {
    throw new Error("Weight and reps must be positive numbers.");
  }

  // A true 1-rep set is already the 1RM — no formula needed.
  if (reps === 1) {
    return buildResult(weight, weight, weight, reps);
  }

  const epley = weight * (1 + reps / 30);
  const brzycki = weight / (1.0278 - 0.0278 * reps);
  const lander = (100 * weight) / (101.3 - 2.67123 * reps);

  return buildResult(epley, brzycki, lander, reps);
}

function buildResult(
  epley: number,
  brzycki: number,
  lander: number,
  reps: number
): OneRepMaxResult {
  const average = (epley + brzycki + lander) / 3;

  const percentages = [95, 90, 85, 80, 75, 70, 65, 60].map((percent) => ({
    percent,
    weight: Number(((average * percent) / 100).toFixed(1)),
  }));

  return {
    epley: Number(epley.toFixed(1)),
    brzycki: Number(brzycki.toFixed(1)),
    lander: Number(lander.toFixed(1)),
    average: Number(average.toFixed(1)),
    isReliable: reps <= RELIABLE_REP_CEILING,
    percentages,
  };
}
Enter fullscreen mode Exit fullscreen mode

A couple of deliberate choices worth calling out:

  • reps === 1 short-circuits the regression entirely. If someone genuinely lifted the weight once, that is the 1RM — running it through Brzycki's denominator would introduce error where none exists.
  • isReliable is computed data, not a UI decision. The component doesn't decide when to warn the user; the calculation layer decides, and the component just renders whatever it's told. That keeps the "is this trustworthy" logic in one place instead of scattered across render conditionals.
  • Percentage chart is generated from the average of all three formulas, not just Epley — so a single formula's blind spot doesn't skew every training percentage a lifter programs off of.

The Client Component: No Debounce, No Excuse

The entire reason the legacy calculators debounce input is almost always a symptom of doing too much work per keystroke, or running the calculation on the server. Here, the math above runs in microseconds, so useMemo recalculates synchronously on every render with zero perceptible delay — typing "225" shows the correct answer for "225," not a stale answer for "22."

// components/CalculatorForm.tsx
"use client";

import { useMemo, useState } from "react";
import {
  calculateOneRepMax,
  type OneRepMaxResult,
} from "@/lib/calculateOneRepMax";

export default function CalculatorForm() {
  const [weight, setWeight] = useState(225);
  const [reps, setReps] = useState(5);

  const result: OneRepMaxResult | null = useMemo(() => {
    if (weight <= 0 || reps <= 0) return null;
    return calculateOneRepMax({ weight, reps });
  }, [weight, reps]);

  return (
    <div className="w-full rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm">
      <div className="grid grid-cols-2 gap-4">
        <label className="flex flex-col gap-1 text-sm font-medium text-zinc-600">
          Weight Lifted
          <input
            type="number"
            value={weight}
            onChange={(e) => setWeight(Number(e.target.value))}
            className="rounded-lg border border-zinc-300 px-3 py-2 text-zinc-900 outline-none focus:border-indigo-500"
          />
        </label>

        <label className="flex flex-col gap-1 text-sm font-medium text-zinc-600">
          Reps Completed
          <input
            type="number"
            value={reps}
            onChange={(e) => setReps(Number(e.target.value))}
            className="rounded-lg border border-zinc-300 px-3 py-2 text-zinc-900 outline-none focus:border-indigo-500"
          />
        </label>
      </div>

      {result && (
        <div className="mt-6 space-y-4">
          <div className="grid grid-cols-3 gap-2 text-center">
            <FormulaCard label="Epley" value={result.epley} />
            <FormulaCard label="Brzycki" value={result.brzycki} />
            <FormulaCard label="Lander" value={result.lander} />
          </div>

          <div className="rounded-xl bg-indigo-50 p-4 text-center">
            <p className="text-xs font-semibold uppercase tracking-wide text-indigo-500">
              Estimated 1-Rep Max
            </p>
            <p className="text-3xl font-black text-indigo-700">
              {result.average}
            </p>
          </div>

          {!result.isReliable && (
            <p className="rounded-lg bg-amber-50 px-3 py-2 text-sm font-medium text-amber-700">
              ⚠ Estimates beyond 10 reps lose accuracy — treat this as a
              rough ceiling, not a precise number.
            </p>
          )}

          <div className="space-y-1">
            {result.percentages.map((row) => (
              <div
                key={row.percent}
                className="flex justify-between text-sm text-zinc-600"
              >
                <span>{row.percent}%</span>
                <span className="font-mono text-zinc-900">{row.weight}</span>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

function FormulaCard({ label, value }: { label: string; value: number }) {
  return (
    <div className="rounded-lg border border-zinc-200 p-3">
      <p className="text-xs text-zinc-500">{label}</p>
      <p className="font-mono text-lg font-semibold text-zinc-900">
        {value}
      </p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Nothing here is exotic — that's the point. Two controlled inputs, one memoized derived value, no debounce, no loading state, no client-side fetch. The absence of machinery is the performance win.


Structured Content Isn't the Enemy — Ad Networks Are

It's worth being precise about something here, because it's easy to conflate two very different things: content bloat and content that actually helps search and users.

The production page behind this calculator ships FAQ schema, breadcrumb schema, and a genuinely useful explainer section (how to pick a working set, why full range of motion matters, what counts as a "good" 1RM for your bodyweight). That's not bloat — a lifter searching "how to calculate 1 rep max without testing" is looking for exactly that context, and structured JSON-LD lets Google surface it as a rich result instead of a blue link.

Bloat is specifically the stuff that serves someone other than the reader: autoplay video ads, pop-unders, tracking pixels, artificial debounce delays that exist only because the page is too heavy to recalculate instantly. You can ship a content-rich, SEO-solid page and have a zero-ad, zero-CLS, instant-feedback calculator sitting inside it — those aren't in tension. The legacy tools didn't get slow because they had good content; they got slow because the business model was ads, and the calculator was never the priority.


Try It Against Your Own Numbers

The formulas above, the percentage chart, the reliability warning at high rep counts — it's all live right now:

HypeCalc 1-Rep Max Calculator →

Type your last heavy set in and watch all three formulas resolve before you finish typing the rep count. No pop-under. No debounce. No pre-workout quiz standing between you and your numbers.


Discussion

A few things I'd genuinely like this community's take on:

  1. Where's the line between "structured, SEO-rich content" and "bloat"? I'd argue schema markup and a real FAQ section are the opposite of bloat — do you agree, or does anything beyond the calculator itself count against a tool in your eyes?
  2. Showing three formulas instead of one is more UI complexity for more trustworthiness — is that trade worth it for a two-input calculator, or should tools like this just pick the "best" formula and hide the disagreement?
  3. What's a debounced input you've hit recently that had zero technical reason to be debounced? I have a hunch most of them exist to hide server round-trips that shouldn't be happening client-side at all.

Top comments (0)