close

DEV Community

Mokshraj(ssr7)
Mokshraj(ssr7)

Posted on

Every 401(k) fee analyzer wanted my login. The data was in a PDF they already mail me.

second post here. The first was about a bank reconciler that can't upload anything; this one is
about a smaller tool with one genuinely interesting bug in it.

Here's the thing I didn't know until I went looking: if you have a 401(k) in the US, your plan is
legally required to mail you an annual fee disclosure. It's called the 404(a)(5) notice. It lists
every fund in your plan, every expense ratio, and any administration fee. It arrives once a year
and — going by everyone I asked — approximately nobody opens it.

So the data is already sitting in your house. But every fee analyzer I tried wanted me to connect
my actual retirement accounts through an aggregator first. Handing over brokerage credentials to
find out what I'm being charged felt like a strange trade for arithmetic I could do from a
document I already had.

I built the version that just takes the numbers: https://stepwisecalc.com/tools/401k-fee-analyzer
(free, no signup, nothing leaves the browser)

The rest of this is the three parts that were actually interesting to write.

The first bug was an average

My first version averaged the expense ratios. This is wrong, and it's wrong in a way that looks
completely fine until you check it.

Say you hold $90,000 in an index fund charging 0.10%, and $10,000 in an active fund charging
1.00%. The mean of those two numbers is 0.55%. Your actual blended cost is 0.19%.


ts
// wrong: every fund counts equally regardless of how much is in it
const naive = funds.reduce((s, f) => s + f.expenseRatio, 0) / funds.length;

// right: a fee is charged against a balance, so weight by balance
const total = funds.reduce((s, f) => s + f.balance, 0);
const weighted = funds.reduce((s, f) => s + f.balance * f.expenseRatio, 0) / total;
Nearly 3x off, and in the direction that makes your plan look worse than it is. The general shape of the bug — averaging rates that apply to different-sized quantities — is one I've now seen in enough places that I think of it as its own category. An expense ratio isn't a number you can average with other numbers. It's a rate attached to a quantity, and the quantity is the point.

Fix it and the units make sense again: 0.19% of $100,000 is $190, which is the $90 and the $100 the two funds actually charge.

Flat fees don't fit in a percentage model at all
Plenty of plans charge a flat recordkeeping fee — a fixed number of dollars a year, independent of your balance. To get an all-in cost you have to express it as a percentage, which means dividing by the balance, which means it isn't a constant any more:

const flatAsPercent = (adminFeeFlat / totalBalance) * 100;
const allIn = weightedExpenseRatio + adminFeePercent + flatAsPercent;
Running the numbers on a $100/year flat fee:

On a $5,000 balance, all-in cost is 2.10%
On a $250,000 balance, all-in cost is 0.14%
Fifteen times heavier on the smaller balance, for identical service. I hadn't expected the tool to surface a fairness property, but that's what falls out of the arithmetic, and it's the number a new employee with a small balance most needs to see.

Testing a closed form against a simulation
The projection uses the closed-form future value with monthly compounding and end-of-month contributions:

const monthly = (grossReturnPercent - annualCostPercent) / 100 / 12;
const growth = Math.pow(1 + monthly, months);
return startBalance * growth + monthlyContribution * ((growth - 1) / monthly);
I don't trust myself with that formula. Off-by-one on the contribution timing, a sign error, the degenerate case where the net return is exactly zero and you divide by it — all of them produce a number that looks plausible and is wrong by tens of thousands of dollars.

So the test doesn't assert a magic constant. It runs a month-by-month loop and demands the two agree:

it('matches an iterative month-by-month simulation', () => {
  const closed = futureValue(50000, 500, 7, 0.5, 20);
  let balance = 50000;
  const monthly = (7 - 0.5) / 100 / 12;
  for (let m = 0; m < 240; m += 1) balance = balance * (1 + monthly) + 500;
  expect(closed).toBeCloseTo(balance, 0);
});
Two independent implementations of the same idea, one obviously correct and slow, one fast and easy to get subtly wrong. If they disagree, one of them is broken and I don't have to guess which kind of broken. The zero-return case gets its own test, because that's where the closed form divides by zero and has to fall back to plain addition.

What the whole thing exists to show, on a $100,000 balance with $500/month for 30 years at a 7% gross return:

At 1.00% all-in: $1,104,515
At 0.05% (a broad index fund): $1,403,637
Difference: $299,122, or 21% of the final balance
Same contributions, same market, same everything. One number changed.

The part where I refused to make it look better
Modelling fees as a straight one-for-one reduction in annual return is the standard approach and it's what I did. But it's a floor, not the truth: funds carry internal trading costs that never appear in the expense ratio, some share classes route revenue sharing back to the plan's recordkeeper, and a target-date fund can layer its own fee on top of the funds it holds.

I put that in the UI rather than a footnote, and it was tempting not to — "your fees are at least this bad" is a weaker headline than a precise-looking figure. But a tool whose entire pitch is that it doesn't want anything from you shouldn't then overstate its own precision.

Built in TypeScript, no dependencies in the engine, everything client-side. Same site as the reconciler I posted about last time, if you saw that one.

What I'd like to know: has anyone here actually read their 404(a)(5) disclosure, and did the numbers on it match what you expected? I've now looked at a handful and the admin fee is almost always the surprise.

Enter fullscreen mode Exit fullscreen mode

Top comments (0)