All posts
Frontend·Apr 2026·7 min

Next.js 16 at 10K DAU: The Dashboard Optimization That Cut Loads by 33%

Composite indexes, N+1 batching, and React.memo against the right boundary. A practical story about identifying the one table that was killing dashboard load times.

Next.js 16 at 10K DAU: The Dashboard Optimization That Cut Loads by 33%

A 10K DAU dashboard was doing N+1 queries — one fetch for the table, then one per row for related entities. PostgreSQL's connection pool hit exhaustion every morning at 9am. The frontend was also re-rendering the whole table on every keystroke in the filter input.

The bottleneck

The dashboard loaded 50 rows. For each row, it fetched the related entity (customer name, order count, last activity). That's 51 queries per page load — 5,050 queries per second at peak.

// The N+1 pattern
const rows = await db.query('SELECT * FROM orders LIMIT 50');
for (const row of rows) {
  row.customer = await db.query('SELECT * FROM customers WHERE id = $1', [row.customer_id]);
  row.orderCount = await db.query('SELECT COUNT(*) FROM orders WHERE customer_id = $1', [row.customer_id]);
}

The fix: batched JOIN + array aggregation

One query instead of 51:

SELECT
  o.*,
  c.name AS customer_name,
  COUNT(o2.id) AS order_count,
  MAX(o2.created_at) AS last_activity,
  array_agg(json_build_object('id', o2.id, 'total', o2.total)) AS recent_orders
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN orders o2 ON o2.customer_id = o.customer_id AND o2.created_at > NOW() - INTERVAL '30 days'
WHERE o.tenant_id = $1
  AND o.status = ANY($2)
  AND o.created_at > $3
GROUP BY o.id, c.name
ORDER BY o.created_at DESC
LIMIT 50

The indexes

The three filters that actually get used are `tenant_id`, `created_at`, and `status`. I added a composite index:

CREATE INDEX idx_orders_tenant_status_created
  ON orders (tenant_id, status, created_at DESC);

Query planner now does an index scan instead of a sequential scan. Latency drops from 180ms to 12ms.

The frontend: memoize the right boundary

The table was re-rendering on every keystroke because the filter input updated state that the table consumed. The fix: `React.memo` on the row component + debounce the filter input by 150ms.

const Row = React.memo(function Row({ order }: { order: Order }) {
  return (
    <tr>
      <td>{order.customer_name}</td>
      <td>{order.order_count}</td>
      <td>{formatDate(order.last_activity)}</td>
    </tr>
  );
});

// Debounce the filter
const [filter, setFilter] = useState('');
const debouncedFilter = useDebounce(filter, 150);

Now the table only re-renders when the debounced filter changes — not on every keystroke.

The payoff

  • p50 dashboard load: 1.2s → 0.8s (33% faster)
  • Connection pool utilization: 94% → 31%
  • Total code change: 340 lines across two files

The lesson: most "performance problems" are actually one query doing N+1 and one component re-rendering too often. Fix those two and everything else follows.