From 53 to 93: how I fixed performance in my Next.js app
A walkthrough of the real fixes that took Life OS from a Lighthouse score of 53 to 93 — font loading, loading skeletons, N+1 queries, and why performance in Next.js is not just about adding suspense boundaries.
After finishing the ten core modules of Life OS, I ran Lighthouse for the first time on a production build. The score was 53. That is not a disaster — it is a personal app, not a marketing site — but it is also low enough to feel slow in daily use, and I use this thing every day.
The problems were real. Pages would hang before showing anything. The font would flash as the page loaded. The projects page was making one database query per project to count tasks. None of these were difficult to fix individually. Together, they added up to an app that felt sluggish despite having a clean architecture.
This is a walkthrough of what I fixed and why each change mattered.
The font was blocking render
The original setup loaded DM Sans from Google Fonts via a CSS import in globals.css:
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap');This seems fine. It has display=swap. The problem is that a CSS @import is a render-blocking resource. The browser has to fetch the stylesheet before it can continue parsing, which means the page stalls before it even starts laying out.
Next.js has a built-in fix for this with next/font. The change was to replace the CSS import with a proper font module:
// src/app/layout.tsx
import { DM_Sans } from 'next/font/google'
const dmSans = DM_Sans({
subsets: ['latin'],
weight: ['300', '400', '500'],
variable: '--font-dm-sans',
display: 'swap',
})Then apply it to the root element:
<html lang="en" className={dmSans.variable}>Under the hood, next/font downloads and self-hosts the font at build time, eliminating the external network request entirely. The font file is served from your own origin alongside the rest of your assets. No DNS lookup, no connection to fonts.googleapis.com, no render-blocking CSS import. The browser can start painting immediately.
This single change had the largest impact on the score. Eliminating a render-blocking external resource is one of the highest-leverage optimizations available in Next.js.
Pages were hanging before showing anything
Life OS uses the App Router, which means every page is a React Server Component by default. Server components are great for data fetching, but they also mean the page does not render until the data is ready. If a page does a slow database query, the user sees nothing until it completes.
The fix is loading.tsx. Next.js treats a loading.tsx file in a route directory as an instant fallback that renders while the server component is loading. The moment the user navigates, the loading UI appears — no waiting for data.
I added loading skeletons to the four heaviest pages: Inbox, Tasks, Learning, and Calendar.
// src/app/(app)/tasks/loading.tsx
export default function TasksLoading() {
return (
<div className="p-4 md:p-[26px]">
<div className="h-4 w-12 rounded animate-pulse mb-5"
style={{ background: 'var(--border-default)' }} />
<div className="flex flex-col gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-11 rounded-lg animate-pulse"
style={{ background: 'var(--stat-bg)' }} />
))}
</div>
</div>
)
}The calendar skeleton is more specific — a 7-column grid of placeholder boxes that mirrors the actual calendar layout:
// src/app/(app)/calendar/loading.tsx
export default function CalendarLoading() {
return (
<div className="p-4 md:p-[26px]">
<div className="h-4 w-16 rounded animate-pulse mb-5"
style={{ background: 'var(--border-default)' }} />
<div className="grid grid-cols-7 gap-1 mb-4">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-6 rounded animate-pulse"
style={{ background: 'var(--stat-bg)' }} />
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 35 }).map((_, i) => (
<div key={i} className="h-20 rounded-lg animate-pulse"
style={{ background: 'var(--stat-bg)' }} />
))}
</div>
</div>
)
}The key insight here is that loading.tsx improves perceived performance more than actual performance. The data fetching time does not change. What changes is that the user sees something immediately instead of staring at a blank page. That difference is enormous in how the app feels.
Lighthouse measures time to interactive and first contentful paint. Both improve significantly when the browser can render something meaningful before the server finishes its work.
The N+1 query problem
This was the worst offender and the most avoidable.
The Projects page was rendering a task count badge on each project card — how many tasks total, how many done. The original implementation fetched all projects, then looped over them and issued a database query per project:
// before
const all = await getAllProjects(user.id)
const counts = await Promise.all(
all.map(p => getProjectTaskCounts(p.id).then(c => ({ id: p.id, ...c })))
)With eight projects, this is nine database round trips. The Promise.all runs them in parallel, so the wall clock time is the latency of the slowest query rather than the sum of all of them — but you are still opening eight connections to the database, waiting for eight responses, and processing eight result sets. At any non-trivial project count this becomes noticeable.
The fix was a single grouped query:
// src/services/projects.ts
export async function getProjectTaskCountsBatch(
userId: string,
): Promise<Record<string, { total: number; done: number }>> {
const rows = await db
.select({ projectId: tasks.projectId, status: tasks.status, cnt: count() })
.from(tasks)
.where(and(eq(tasks.userId, userId), isNotNull(tasks.projectId)))
.groupBy(tasks.projectId, tasks.status)
const map: Record<string, { total: number; done: number }> = {}
for (const row of rows) {
if (!row.projectId) continue
if (!map[row.projectId]) map[row.projectId] = { total: 0, done: 0 }
map[row.projectId].total += row.cnt
if (row.status === 'done') map[row.projectId].done += row.cnt
}
return map
}One query. The database engine does the grouping. The result is a map keyed by project ID that the page component can look up in O(1) per project. The Projects page went from N+1 queries to two:
const [all, countMap] = await Promise.all([
getAllProjects(user.id),
getProjectTaskCountsBatch(user.id),
])The rule of thumb is simple: if you find yourself calling a database function inside a loop, that is an N+1 query. It almost always has a batch equivalent.
Fetching everything and filtering in JavaScript
The Areas page had a similar but different problem. When you navigate to, say, the Work area, the page was fetching all projects and all goals for the user, then filtering in JavaScript:
// before
const [tasks, allProjects, allGoals, habits] = await Promise.all([
getTasksByArea(user.id, area.slug),
getAllProjects(user.id),
getAllGoals(user.id),
getHabitsByArea(user.id, area.slug),
])
const projects = allProjects.filter(p => p.areaSlug === area.slug && p.status === 'active')
const goals = allGoals.filter(g => g.areaSlug === area.slug && g.status === 'active')This is transferring data that gets thrown away. If there are 20 projects across all areas and 5 are in Work, the query was fetching 20 and discarding 15. For small datasets this does not matter much, but it is genuinely wasteful and it is easy to fix.
The fix was to add scoped service functions that push the filter into the database query:
// src/services/projects.ts
export async function getProjectsByArea(userId: string, areaSlug: string): Promise<Project[]> {
return db
.select()
.from(projects)
.where(and(
eq(projects.userId, userId),
eq(projects.areaSlug, areaSlug),
eq(projects.status, 'active')
))
.orderBy(asc(projects.createdAt))
}The Areas page now fetches only what it needs:
// after
const [tasks, projects, goals, habits] = await Promise.all([
getTasksByArea(user.id, area.slug),
getProjectsByArea(user.id, area.slug),
getGoalsByArea(user.id, area.slug),
getHabitsByArea(user.id, area.slug),
])The principle here is that the database is better at filtering than JavaScript. Pushing the WHERE clause into the query means you only pay for the rows you actually use.
Counting rows without fetching rows
Several service functions needed a row count — how many learning modules exist so a new one can be assigned the right sort order, how many tasks belong to a goal. The original pattern was to fetch all rows and check the length:
// before
const existing = await db.select().from(learningModules)
.where(and(eq(learningModules.userId, userId), eq(learningModules.pathId, pathId)))
const sortOrder = existing.lengthThis fetches the full row — every column — for every module in the path, just to get a number. The fix is SELECT COUNT():
// after
const [countResult] = await db.select({ cnt: count() }).from(learningModules)
.where(and(eq(learningModules.userId, userId), eq(learningModules.pathId, pathId)))
const sortOrder = countResult?.cnt ?? 0One integer comes back instead of N full rows. For a path with 20 modules, the difference in data transferred is roughly 20× — and the database can often satisfy a COUNT() query from index statistics without a full table scan.
The result
After these changes, Lighthouse on a local production build sits above 90. The biggest single contributors were the font fix (which affects first paint directly) and the loading skeletons (which affect perceived performance the most). The query optimizations matter more at scale, but they also make the app cheaper to run and easier to reason about.
The pattern that connects all of these fixes is the same: do less, earlier, and closer to the data. Load the font from your own origin. Show something on screen before the data arrives. Ask the database for exactly what you need, not everything filtered in memory. Count with COUNT() instead of counting fetched rows.
None of these require a different architecture or a different framework. They are the application of basic principles that Next.js makes straightforward once you know where to look.