My dashboard took 7.6 seconds to render fifteen numbers 🐌

μž‘μ„±μž

μΉ΄ν…Œκ³ λ¦¬:

← ν”Όλ“œλ‘œ
DEV Community · Soumyadeep Dey · 2026-07-23 개발(SW)

This is a submission for DEV’s Summer Bug Smash: Clear the Lineup powered by Sentry.

Not fifteen tables. Fifteen numbers. Four stat tiles, three donut slices, six bars, and a list of four recent invoices.

Seven and a half seconds, on the page my clients land on after login.

It had been doing this for months and I never noticed, because my development database held four invoices. On four invoices it takes 111 ms and feels instant. This bug only existed at a volume I never had locally, which is the most dangerous kind there is, because it ships and then it waits.

Here’s how Sentry found it, what was actually broken (three separate things), and the numbers afterwards.

The app, and why the stakes are real

DevLabs is the agency platform my team runs the business on. Next.js 16, React 19, MongoDB/Mongoose, ~34k lines. Marketing site, internal admin dashboard, and a client-facing portal holding real invoices, real contracts, and real customer records.

That last part matters twice in this post: once because slow pages cost us client trust, and once because it constrained how I was allowed to instrument.

A note on the code below. This is a private client-facing codebase, so I can’t link the repo. Everything here is the real diff, quoted verbatim from the files it lives in, with nothing invented for the write-up. Every measurement comes from a harness I’ve included in full, so you can point the same one at your own app and get your own numbers rather than taking mine on faith.

Step 1: Sentry, and the integration that isn’t on by default

I wired up @sentry/nextjs expecting the trace to point at the slow query.

Instead the whole request came back as one opaque server span. No database spans at all.

Here’s the thing that cost me an evening, and the single most useful thing in this post:

Mongo and Mongoose spans are not in Sentry’s default integration set. You have to name them.

I verified it rather than trusting my assumptions:

$ node -e "const n=require('@sentry/node');
  const d=n.getDefaultIntegrations({});
  console.log('total:', d.length);
  console.log('mongo:', d.map(i=>i.name).filter(x=>/[Mm]ongo/.test(x)))"

total: 17
mongo: []      # <- empty. they're opt-in.

Enter fullscreen mode Exit fullscreen mode

Seventeen integrations out of the box, and your database is in none of them.

// src/instrumentation.ts
// Without these, a dashboard request traces as ONE opaque server span
// and the entire data layer stays invisible.
const integrations = isNode
  ? await import("@sentry/node").then((node) => [
      node.mongoIntegration(),
      node.mongooseIntegration(),
    ])
  : [];

Sentry.init({
  dsn: DSN,
  tracesSampleRate,
  integrations,
  sendDefaultPii: false,   // this app holds customer and invoice records
});

Enter fullscreen mode Exit fullscreen mode

With those two lines the waterfall opened up, and the database started talking:

Span Duration mongodb {"getMore": ...} 4.42 s mongoose.projects.find 2.67 s mongodb {"getMore": ...} 1.35 s mongoose.customers.find 134 ms mongodb {"find": {"firebaseUid": "?"}} 86 ms

The thing I did not expect was getMore.

getMore is MongoDB’s cursor-continuation command. You only see it when a result set is too large to come back in one batch, so the driver goes back to the server again and again to stream the remainder. Two getMore spans totalling 5.77 seconds is the database telling you, in its own words, you asked for so much data I had to deliver it in installments.

Two mongodb getMore spans of 4.42s and 1.35s next to mongoose.projects.find at 2.67s

Two getMore spans (4.42s and 1.35s) in a single dashboard load, beside mongoose.projects.find at 2.67s, which is the second bug.

That’s a much sharper diagnosis than “the query is slow.” A slow find could be a missing index. getMore dominating a trace means the problem is volume of bytes, not lookup speed. No index would have saved this. It pointed straight at over-fetching before I had read a single line of the query.

Opening the full waterfall made the proportion impossible to argue with:

http.server  GET /dashboard ................. 14.89 s
  db  mongoose.invoices.find ................ 13.32 s   <-- one span
  db  mongoose.users.findOne ................ 845 ms
  resolve page components ................... 3.93 ms
  build component tree ...................... 11.73 ms

Sentry's own span breakdown:  db 77%  Β·  default 12%  Β·  function.nextjs 12%

Enter fullscreen mode Exit fullscreen mode

One span was 89% of the request.

Read that number honestly. 14.89 s is a development load: Turbopack compiling, no production optimizations, and Sentry sampling at 100%. The clean figure is the 7,580 ms from the benchmark below, measured with the same code paths and no instrumentation overhead. What the trace contributes is not magnitude, it’s shape: which span, what share, and why. Those hold regardless of environment.

(The saslStart, saslContinue, and createIndexes spans above it in the trace are MongoDB’s connection handshake on cold start. They fire once per process and are not part of this bug.)

Sentry span list for GET /dashboard sorted by duration: the request at 14.89s, mongoose.invoices.find at 13.32s, and a mongodb getMore span at 9.62s

Sorted by duration, the whole bug reads top to bottom: a 14.89s request, one 13.32s query inside it, and a getMore cursor span at 9.62s.

Sentry trace waterfall showing db at 77 percent of the GET /dashboard transaction

Sentry’s own span breakdown for the request: **db 77%, default 12%, function.nextjs 12%.

Session Replay needed one more decision before I turned it on. This dashboard renders real customer names, invoice totals, and contract titles:

Sentry.replayIntegration({
  maskAllText: true,     // non-negotiable: replays record layout, never client data
  blockAllMedia: true,
}),

Enter fullscreen mode Exit fullscreen mode

A replay that leaks a client’s invoice total is not a debugging tool, it’s an incident.

Step 2: make it reproducible

Sentry told me where. To know whether a fix worked I needed a number I could re-run, and a database that actually hurt.

So my first commit wasn’t a fix. It was a volume generator, with two rules because it points at whatever MONGODB_URI points at:

  1. It only inserts. Never updates or deletes a document it didn’t create.
  2. Everything it writes is tagged, so cleanup removes exactly the generated rows and nothing else.
const SEED_TAG = "[perf-seed]";

// Stands in for the base64 data URI a real uploaded logo becomes.
// Every invoice carries its own copy.
const FAKE_LOGO = `data:image/png;base64,${"iVBORw0KGgoAAAANSUhEUg".repeat(360)}`;

Enter fullscreen mode Exit fullscreen mode

Plus a read-only benchmark that runs the exact functions the page runs:

npm run seed:perf -- --yes        # 2,000 invoices, 40 projects
npm run bench:dashboard           # measures, writes nothing

Enter fullscreen mode Exit fullscreen mode

corpus      2004 invoices, 42 projects

getInvoicesAdmin()      6668.8 ms    22358.6 KB read
getDashboardOverview()   911.4 ms     2490.0 KB read, 4806 tasks scanned
                                      8 activity rows kept
total server time       7580.2 ms

Enter fullscreen mode Exit fullscreen mode

24.3 MB off the wire, 7.6 seconds, to render fifteen numbers.

What one dashboard load was actually doing

flowchart TD
    A["GET /dashboard"] --> B["getInvoicesAdmin()"]
    A --> C["getDashboardOverview()"]

    B --> D["Invoice.find()<br/>no filter Β· no limit Β· no projection"]
    D --> E["2,004 FULL documents<br/>incl. base64 logo per invoice<br/>21.8 MB"]
    E --> F["rebuild every invoice<br/>invoiceFieldsFrom() x 2004"]

    C --> H["Project.find().select('name board activity')"]
    H --> I["every board Β· every activity row<br/>2.4 MB Β· 4,806 tasks"]
    I --> J["reduce in JS to<br/>8 items + 4 counters"]

    F --> K["ship 2,004 rows to the browser"]
    J --> K
    K --> L["render 15 numbers"]

    style E fill:#c62828,color:#fff
    style I fill:#c62828,color:#fff
    style L fill:#2e7d32,color:#fff

Enter fullscreen mode Exit fullscreen mode

Three distinct bugs.

Bug 1: a base64 logo, fetched 2,004 times, to print a serial number

const invoices = await Invoice.find()   // no filter
  .populate(INVOICE_POPULATE)           // no limit
  .sort({ createdAt: -1 })              // no projection
  .lean();

Enter fullscreen mode Exit fullscreen mode

Invoice.find() with no projection pulls every field of every document. In this schema that includes fields.companyDetails.logo, and uploaded logos are stored as base64 data URIs. Each invoice carries its own full copy of an image.

A list view rendering a serial number, a customer name, and a total was dragging an image per row across the network. Times two thousand.

The fix is pure exclusion, zero contract change:

+const INVOICE_LIST_EXCLUDE = "-fields.companyDetails.logo -fields.metadata -access";

 const invoices = await Invoice.find()
+  .select(INVOICE_LIST_EXCLUDE)
   .populate(INVOICE_POPULATE)

Enter fullscreen mode Exit fullscreen mode

The detail view still selects everything, so opening an invoice is untouched.

22,358 KB β†’ 6,046 KB. 6,669 ms β†’ 1,597 ms.

Bug 2: the comment that was true about the output and false about the input

// Aggregated CRM data for the dashboard Overview tab. One query pass,
// everything trimmed to what the cards render.

Enter fullscreen mode Exit fullscreen mode

That comment is correct about what comes out: eight activity rows, four counters, four project bars.

It is completely wrong about what goes in:

Project.find().select("name board activity").lean()

Enter fullscreen mode Exit fullscreen mode

Every project’s entire board, meaning every column and every task, plus its entire activity log. Activity is append-only. It grows forever. The dashboard’s cost was tied to my whole business history, on every load, to display eight rows.

4,806 tasks scanned to produce four numbers.

The fix is to do the reduction where the data already lives:

Project.aggregate([{
  $facet: {
    recentActivity: [
      { $unwind: "$activity" },
      { $sort: { "activity.time": -1 } },
      { $limit: 8 },
      { $project: { _id: 0, projectId: "$_id", projectName: "$name",
                    text: "$activity.text", time: "$activity.time" } },
    ],
    taskTotals: [
      { $unwind: "$board" }, { $unwind: "$board.tasks" },
      { $group: { _id: "$board.tasks.status", count: { $sum: 1 } } },
    ],
    perProject: [
      { $unwind: "$board" }, { $unwind: "$board.tasks" },
      { $group: {
          _id: "$_id", name: { $first: "$name" }, total: { $sum: 1 },
          done: { $sum: { $cond: [{ $eq: ["$board.tasks.status", "Done"] }, 1, 0] } },
      }},
      { $sort: { total: -1 } }, { $limit: 4 },
    ],
  },
}])

Enter fullscreen mode Exit fullscreen mode

One round trip, three reductions. MongoDB counts 4,806 tasks server-side and hands back four numbers.

911 ms β†’ 74 ms. 2,490 KB β†’ 2.0 KB. A 99.9% cut in bytes returned.

Bug 3: the comparator that answers “yes” in both directions

This one isn’t about speed. It had been sitting in two files for months.

.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1))

Enter fullscreen mode Exit fullscreen mode

Read it carefully. It never returns 0.

When two invoices share a timestamp, it says a comes before b. Ask the other way round and it says b comes before a. Both, at once.

compare(a, b) === -1   // "a first"
compare(b, a) === -1   // "b first"

Enter fullscreen mode Exit fullscreen mode

That isn’t a preference, it’s a contradiction, and it breaks the contract Array.prototype.sort is built on. V8’s TimSort then resolves it based on array length and starting position rather than on your data.

Equal timestamps are routine here: bulk imports, seed scripts, fast manual entry. And this runs inside a "use client" component, so server and browser can legitimately disagree about row order. That’s a hydration mismatch waiting for the right array length.

sort comparator            before      after
  self-contradictory        true        false
  order independent of input true       true

Enter fullscreen mode Exit fullscreen mode

An honest note. The contract violation is proven, and true is not ambiguous. But my 64-element reordering test did not produce a visible reorder, because V8 uses insertion sort below a threshold where the inconsistency happens to be harmless. So this is a latent bug: guaranteed invalid, with a symptom that depends on array size and engine version. I’m reporting what I measured, not what would have made a better story.

-.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1))
+.sort((a, b) => (a.createdAt === b.createdAt ? 0 : a.createdAt < b.createdAt ? 1 : -1))

Enter fullscreen mode Exit fullscreen mode

Results

                        BEFORE                          AFTER
getInvoicesAdmin       β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 6669ms      β–ˆβ–ˆβ–ˆβ–ˆ 1597ms
getDashboardOverview   β–ˆβ–ˆβ–ˆ 911ms                         ▏ 74ms
──────────────────────────────────────────────────────────────────
TOTAL                  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7580ms    β–ˆβ–ˆβ–ˆβ–ˆ 1671ms

Enter fullscreen mode Exit fullscreen mode

Metric Before After Change Total server time 7,580 ms 1,671 ms 4.5Γ— faster getInvoicesAdmin() 6,669 ms 1,597 ms 4.2Γ— getDashboardOverview() 911 ms 74 ms 12.3Γ— Read from MongoDB 24,849 KB 6,048 KB βˆ’76% Overview bytes returned 2,490 KB 2.0 KB βˆ’99.9% Comparator self-contradictory true false fixed

Same corpus, median of 5 runs.

And the same Sentry view, after:

Sentry span list for GET /dashboard after the fix, with the getMore spans absent

The interesting part is not that the bars got shorter. **The getMore spans are gone.* Once the projection stops pulling a base64 logo per invoice, the result set fits in a single batch and MongoDB has nothing left to page.*

Output verified identical. 4,806 tasks scanned, 8 activity rows, 4 project bars, before and after. A performance fix that changes behaviour is just a different bug.

Does your app have this? Three checks

This bug class isn’t specific to my code. If you run Mongo behind a dashboard, here’s how to check in about ten minutes.

1. Grep for unprojected finds.

rg "\.find\(\)" --type ts -A 3 | rg -v "select|limit|projection"

Enter fullscreen mode Exit fullscreen mode

Every hit is a query pulling whole documents. Ask what the caller actually reads. If it’s fewer than half the fields, add a .select().

2. Look for a field that can be huge.

Base64 images, rich-text bodies, append-only logs, embedded arrays with no cap. One of these inside a list query is the whole bug. In my schema it was companyDetails.logo.

3. Check whether your reduction happens in the wrong place.

If a function loads N documents and returns a constant number of rows, the reduction belongs in the database. $facet does several at once in a single round trip.

And if your traces show no db.query spans at all, you haven’t found a fast app. You’ve found missing integrations.

What I deliberately did not do

The obvious next move is pushing the money math into MongoDB too, $group the totals and skip the documents entirely.

I didn’t, and it’s the decision I’d defend hardest.

export function getTotalValue(data: ZodCreateInvoiceSchema): number {
  let total = getSubTotalValue(data) - getDiscountValue(data);
  for (const detail of data.invoiceDetails.billingDetails) {
    if (detail.type === "percentage") total += (total * detail.value) / 100;
    else total += detail.value;
  }
  return Math.round(total * 100) / 100;
}

Enter fullscreen mode Exit fullscreen mode

Those billingDetails compound sequentially. Each percentage applies to the running total, so order matters. Reproducing that in MongoDB expression language means a $reduce that has to stay byte-identical to this function forever.

That’s two sources of truth for what a customer owes. A dashboard that loads 200 ms faster is not worth an invoice total that disagrees with itself.

So I cut what gets fetched and left the arithmetic in exactly one place.

What I learned

Empty dev databases hide production bugs. Four invoices, 111 ms. Two thousand, 6,669 ms. The code never changed.

Read comments as claims to verify. “Everything trimmed to what the cards render” was true about the output and false about the input, and that gap was the entire bug.

Instrumentation has defaults, and defaults have gaps. Seventeen integrations, and your database is in none of them. An empty waterfall is a finding, not a clean bill of health.

Learn to read the span name, not just the bar. getMore versus find is the difference between “I sent too many bytes” and “I looked in the wrong place.” One is fixed with a projection, the other with an index. The trace told me which before I had read the query.

Report what you measured. The comparator’s contract violation is provable. The visible reorder didn’t reproduce at 64 elements, so I said so.

The harness, in full

I can’t hand you my repo, so here’s the thing that actually did the work. It’s about thirty lines and it’s framework-agnostic: point it at any function your page calls.

// bench.ts (read-only). Runs the same functions the page runs.
const RUNS = 5;

function median(xs: number[]) {
  const s = [...xs].sort((a, b) => a - b);
  const mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}

async function time<T>(fn: () => Promise<T>): Promise<[T, number]> {
  const t0 = performance.now();
  const out = await fn();
  return [out, performance.now() - t0];
}

/** Bytes the driver actually pulls back. Pass the SAME projection your
 *  production code uses, or you'll measure an idealised query.
 *  (I got this wrong first time and the projection looked like a no-op.) */
async function wireBytes(model: Model<any>, select?: string) {
  const q = model.find({});
  if (select) q.select(select);
  return Buffer.byteLength(JSON.stringify(await q.lean()), "utf8");
}

const times: number[] = [];
for (let i = 0; i < RUNS; i++) times.push((await time(getInvoicesAdmin))[1]);

console.log(`time         ${median(times).toFixed(1)} ms`);
console.log(`from mongo   ${(await wireBytes(Invoice) / 1024).toFixed(1)} KB`);
console.log(`rendered     4 tiles, 3 donut counts, 6 bars, 4 rows`);

Enter fullscreen mode Exit fullscreen mode

That last console.log is the whole trick. Print what you fetched next to what you rendered, on the same screen. The gap between those two lines is the bug, and once you can see it you can’t unsee it.

For volume, generate rows that look like your worst real ones (mine embed a base64 logo), tag every generated row with a marker string so cleanup is exact, and never let the generator update or delete anything it didn’t create.

Three bugs. About forty lines changed. 7.6 seconds down to 1.7.

The fix was small. Finding it meant making it hurt first, and turning on the integration that shows you where it hurts.

μ›λ¬Έμ—μ„œ 계속 β†—

μΆ”μΆœ λ³Έλ¬Έ Β· 좜처: dev.to Β· https://dev.to/soumyadeepdey/my-dashboard-took-76-seconds-to-render-fifteen-numbers-5dga

μ½”λ©˜νŠΈ

λ‹΅κΈ€ 남기기

이메일 μ£Όμ†ŒλŠ” κ³΅κ°œλ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. ν•„μˆ˜ ν•„λ“œλŠ” *둜 ν‘œμ‹œλ©λ‹ˆλ‹€