The layers of feedback
The practices in this series keep circling back to feedback, for a reason: the speed of your feedback loop sets the speed of your learning, and the speed of your learning sets almost everything else about how effective you are.
Feedback loops come in layers, and the right fix is different at each one.
- Editor feedback. Compiler errors,
tsc --noEmit, ESLint, inline warnings. Zero latency, the tightest loop that exists. Investing in it, stricter linting, more complete type coverage, pays back with compounding interest. - Unit tests in watch mode. Sub-second, on the file you just changed. If your unit tests don’t run automatically on save (
vitest --watch, or your editor’s built-in runner), you’re leaving real developer experience on the table. - Local rebuild-and-run. A change reflected without a full redeploy. Trivial to script with
tsx watchornodemon(tools that watch your files and rebuild/restart the app automatically on save), and often just left off. - Integration tests. Should run in seconds, not minutes. A slow test doesn’t get run. That’s not a discipline problem, it’s an incentives problem: a four-minute suite gives engineers a reason to skip it before pushing.
- CI. Parallelize aggressively. A twenty-minute pipeline is a morale tax paid on every single PR, and the cost isn’t just the twenty minutes, it’s the context switch of sitting there waiting.
The heuristic: if running tests takes willpower, the tests are too slow. Running them should be the path of least resistance, not a deliberate act you have to talk yourself into.
Diagnosing a slow suite
Don’t guess which tests are slow — your test runner will tell you. Run vitest run --reporter=verbose and look at the per-test timing in the output, or reach for a slow-test reporter plugin that prints your slowest tests by name, sorted. Run that before you touch anything. Guessing “it’s probably the ledger tests” and refactoring them first, when the real cost turns out to be three tests re-seeding a fixture from scratch, wastes the afternoon on the wrong file.
Once you have the list, the slow tests are almost always one of these:
A real network or database call standing in for something that should be faked.
// 340ms — makes a real HTTP call to the card-network sandbox on every run
test("charge rejects an expired card", async () => {
const result = await cardNetworkClient.charge({ card: expiredCard, amountCents: 1000 });
expect(result.status).toBe("declined");
});
// 2ms — same assertion, no network round trip
test("charge rejects an expired card", async () => {
const server = setupServer(
http.post("/charges", () => HttpResponse.json({ status: "declined" })),
);
server.listen();
const client = new CardNetworkClient(server.baseUrl);
const result = await client.charge({ card: expiredCard, amountCents: 1000 });
expect(result.status).toBe("declined");
server.close();
});
The first version is testing the sandbox’s latency plus your assertion; the network call was never the thing you meant to verify.
Serial execution when the tests don’t actually depend on each other. Vitest and Jest both parallelize across test files by default, running each file in its own worker — but a shared resource, one test database, one fixture file, silently forces serialization back on. If two tests write to the same accounts table without cleaning up, running their files concurrently produces flaky failures instead of speed, and the “fix” people reach for is pinning the whole suite to a single worker (--pool=forks --poolOptions.forks.singleFork, or Jest’s --runInBand) entirely rather than isolating the tests.
Heavy setup running per-test instead of per-suite. A setup helper called at the top of every test that recreates a full fixture graph — a customer, an account, three cards, ten transactions — because one test at the bottom needs all of it, is now the tax every other test in the file pays. Move the expensive, shared setup into a beforeAll hook or a lazily-initialized module-level singleton, and let individual tests that need a different state build only the delta.
None of this is exotic tooling. It’s mostly noticing that a test is slow for a reason that has nothing to do with what it’s testing, and cutting that reason out.
When everything downstream is prod
The httptest fix above assumes you control the fake. That assumption breaks down for a service like Ledger’s settlement worker, which talks to a card network, an FX-rate provider, and a KYC vendor — three external systems, none with a staging tier, all of them “prod” whether you like it or not. There’s no environment to point a fast local loop at except the real one, and the real one has rate limits, real money movement, and no reset button.
The fix isn’t to accept slow, flaky tests against live vendors on every run — it’s to separate learning the contract from exercising your logic against it. Record real request/response pairs from the vendor once, with secrets and PII scrubbed, and replay them locally the same way the card-network fake replays a canned response above:
// Recorded once, from an actual sandbox transaction, with the account
// number and auth token scrubbed before it's committed.
const fxRateResponse = { pair: "USD/EUR", rate: 0.9187, as_of: "2026-07-28T14:03:00Z" };
test("convert uses the current rate", async () => {
const server = setupServer(
http.get("/fx-rate", () => HttpResponse.json(fxRateResponse)),
);
server.listen();
// ...exercise the conversion logic against server, same as any other msw fake
server.close();
});
That gets your inner loop back to milliseconds, but it introduces a new failure mode: the recording goes stale the day the vendor changes their response shape, and your fast tests keep passing against a contract that no longer exists in prod. Two things keep that honest: a small number of contract tests — tests whose only job is to confirm the vendor’s real response still matches the shape your recording assumes, not to exercise your own logic — that do hit the real vendor, run on a schedule (nightly, not per-PR) rather than in the fast path, so they fail loudly when the recorded fixture drifts from reality; and, where the vendor supports it, a reserved test identity scoped to their sandbox or test mode — most card networks and payment processors publish specific test card numbers for exactly this reason — so at least one tier of your testing exercises the real system without touching real money. The fast loop stays fast because it’s testing your logic against a pinned contract; the slow, scheduled loop is what tells you when that contract has moved.
Deterministic dev seeds
One feedback loop accelerator gets overlooked constantly. Being able to reset to a known state instantly changes how you work. Without it, testing a specific scenario means manually navigating the app to set up preconditions, a multi-minute chore most engineers will dodge if they can. A seed command that takes five seconds makes scenario-specific development tight instead of loose. Nothing kills a feedback loop faster than “let me set up the scenario again.”
Say you’re fixing the expired-hold bug from learning a codebase with no docs: a fraud hold stuck active past its ExpiresAt, still blocking the funds it was holding. Reproducing that by hand means submitting a real transfer, then either waiting for the hold window to lapse or fiddling with your system clock, every single time you want to check whether your fix actually releases the funds. That’s a five-minute round trip on every iteration, which means you’ll test it twice and then start “just trusting” the fix instead of five hundred round trips down to zero effort.
Instead, make the scenario a named seed:
// seeds/scenarios/expiredHold.ts
export async function seedExpiredHold(db: Queries): Promise<TransferId> {
const transfer = await db.createTransfer({ status: "pending" });
await db.createHold({
transferId: transfer.id,
status: "active",
expiresAt: new Date(Date.now() - 5 * 60 * 1000), // already expired, deterministically
});
return transfer.id;
}
npm run db:reset && npm run seed -- --scenario expired_hold
Two commands, a few hundred milliseconds, and you’re staring at exactly the broken state every time — not “roughly the broken state, if the timing lines up.” Now the iteration loop on the actual fix is: run the cleanup job, query the hold, see it flip to expired, repeat. Seconds, not minutes, and every run starts from the identical state the last one did.
This isn’t free forever — a seed is code, and code rots the same way any other code does. Add a required SettlementNetwork field to Transfer and every seed that constructs a transfer without it starts failing to compile, or, worse, compiles fine but silently produces a transfer that violates an assumption the rest of the app makes. Treat that failure as a feature: a seed breaking on schema change is the seed doing its job, forcing you to decide what the new field should be in each named scenario instead of finding out three weeks later that expired_hold has quietly been seeding transfers the settlement flow can’t actually produce anymore. Keep seeds in version control, reviewed in the same PR as the migration that changes their shape — not a script living on one engineer’s machine that only they know how to keep working.
One flat seed function like SeedExpiredHold works because that scenario is one record in one state. It stops working once the scenario is a sequence: reproducing “a hold that already failed release once, is now on its second retry, and the customer disputed the original transfer in between” means composing several distinct states in order, each with its own failure mode, not filling in one row. Trying to capture that as a single seed function produces either a function with a dozen boolean parameters or a wall of near-duplicate seed functions, one per combination anybody happened to need. The fix is the same one you’d reach for in application code with the same shape of problem: build seeds as small composable steps — seedTransfer, seedHold, expireHold, failRelease, openDispute — each taking the previous state and returning the next, so a multi-step scenario is assembled by calling the steps in order instead of writing a new bespoke function per combination:
const transfer = await seedTransfer(db);
let hold = await seedHold(db, transfer.id);
hold = await expireHold(db, hold.id);
hold = await failRelease(db, hold.id); // first retry attempt failed
await openDispute(db, transfer.id);
That doesn’t make every scenario free — some failure sequences are still worth naming and keeping around because they come up often enough — but it keeps the cost of a new scenario proportional to how many steps it actually needs, instead of paying for a new hand-written function every time.