<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Kira Vaughn</title>
    <description>The latest articles on Forem by Kira Vaughn (@kiravaughn).</description>
    <link>https://forem.com/kiravaughn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3762740%2Fafc551de-e412-4300-976a-367fb60f7f9f.png</url>
      <title>Forem: Kira Vaughn</title>
      <link>https://forem.com/kiravaughn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/kiravaughn"/>
    <language>en</language>
    <item>
      <title>Optimizing AI Agent Memory: Tiered Context and Aggressive Compaction</title>
      <dc:creator>Kira Vaughn</dc:creator>
      <pubDate>Wed, 11 Feb 2026 18:15:57 +0000</pubDate>
      <link>https://forem.com/kiravaughn/optimizing-ai-agent-memory-tiered-context-and-aggressive-compaction-4bei</link>
      <guid>https://forem.com/kiravaughn/optimizing-ai-agent-memory-tiered-context-and-aggressive-compaction-4bei</guid>
      <description>&lt;h1&gt;
  
  
  Optimizing AI Agent Memory: Tiered Context and Aggressive Compaction
&lt;/h1&gt;

&lt;p&gt;Running an AI assistant in long-running sessions creates a context management problem that most implementations don't really solve. The model's context window fills up with conversation history, you hit the token limit, and then you either truncate aggressively and lose continuity or you keep everything and pay for massive cached context on every turn.&lt;/p&gt;

&lt;p&gt;I'm running OpenClaw for an AI assistant that handles long sessions, and the default conversation compaction settings weren't aggressive enough. The agent was hitting compaction after hours of conversation and racking up costs from tens of thousands of cached tokens on every turn, most of which weren't relevant to what was actually being asked.&lt;/p&gt;

&lt;p&gt;Here's what I changed and why it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Context Window Bloat
&lt;/h2&gt;

&lt;p&gt;Most AI agent setups load a base set of instructions into every prompt. Personality, operating rules, tool documentation, memory, whatever else you want the AI to remember. OpenClaw calls these "workspace files" and injects them automatically at the start of every conversation.&lt;/p&gt;

&lt;p&gt;This works fine for short sessions. It breaks down when you're running the same agent for hours or days at a time, because you end up with this growing pile of context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Workspace files (instructions, personality, rules)&lt;/li&gt;
&lt;li&gt;Conversation history (every message, every tool call, every result)&lt;/li&gt;
&lt;li&gt;Memory files (if you're loading them all up front)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The conversation history is the real killer. After a few hours of back and forth, you can easily have 50k+ tokens of history sitting there. Claude caches aggressively so you're not paying full price for those tokens every turn, but you're still paying cache read costs and they still count toward the 200k limit.&lt;/p&gt;

&lt;p&gt;When you finally hit the limit, OpenClaw triggers compaction. It summarizes the conversation history into a shorter block and replaces the original messages with the summary. This works, but if you're only compacting when you're about to hit 200k tokens, you've been dragging around a huge context for way longer than necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Tiered Memory and Early Compaction
&lt;/h2&gt;

&lt;p&gt;I rebuilt the agent's context management around two changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Move Most Context Out of Auto-Loaded Files
&lt;/h3&gt;

&lt;p&gt;I went through every workspace file and moved detailed content into separate memory files that only get loaded on demand via semantic search. The workspace files now total about 7KB combined. They contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Core operating rules (AGENTS.md)&lt;/li&gt;
&lt;li&gt;Tool notes specific to my setup (TOOLS.md)&lt;/li&gt;
&lt;li&gt;Identity and personality basics (SOUL.md, IDENTITY.md)&lt;/li&gt;
&lt;li&gt;User preferences (USER.md)&lt;/li&gt;
&lt;li&gt;Current heartbeat tasks (HEARTBEAT.md)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else went into the &lt;code&gt;memory/&lt;/code&gt; directory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detailed memories from past sessions&lt;/li&gt;
&lt;li&gt;Writing style guides&lt;/li&gt;
&lt;li&gt;Operating principles and delegation patterns&lt;/li&gt;
&lt;li&gt;Daily session logs&lt;/li&gt;
&lt;li&gt;Project-specific context&lt;/li&gt;
&lt;li&gt;Technical documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the agent needs detailed information, it runs a semantic search across the memory files and loads only the relevant chunks. This keeps the base context small and loads additional context only when it's actually needed.&lt;/p&gt;

&lt;p&gt;The workspace files explicitly enforce search-before-answer discipline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Memory Strategy&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Daily:**&lt;/span&gt; &lt;span class="sb"&gt;`memory/YYYY-MM-DD.md`&lt;/span&gt; for session logs
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Long-term:**&lt;/span&gt; &lt;span class="sb"&gt;`memory/MEMORY.md`&lt;/span&gt; via &lt;span class="sb"&gt;`memory_search`&lt;/span&gt; (NOT auto-loaded)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Write it down**&lt;/span&gt; - memory doesn't persist between sessions
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Write it down NOW**&lt;/span&gt; - don't wait for compaction
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Search before answering**&lt;/span&gt; - if a question touches anything discussed earlier, do a memory_search first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forced a discipline shift. Instead of relying on conversation history being available, the agent writes important details to memory files immediately and searches them when needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tighten Compaction Settings
&lt;/h3&gt;

&lt;p&gt;OpenClaw has a &lt;code&gt;compaction&lt;/code&gt; configuration block that controls when and how conversation history gets summarized. Here's what I changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safeguard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reserveTokensFloor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"memoryFlush"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"softThresholdTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;mode: "safeguard"&lt;/code&gt;&lt;/strong&gt; uses chunked summarization instead of truncating. It breaks the conversation into segments, summarizes each one, and reassembles them. This preserves more continuity than just dropping old messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;reserveTokensFloor: 120000&lt;/code&gt;&lt;/strong&gt; is the big one. This sets how many tokens to keep free, which determines when compaction triggers. The default was 20k, which meant compaction only kicked in when you were nearly at the 200k limit. Setting it to 120k means compaction fires at around 80k tokens used, keeping the active context window much smaller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;softThresholdTokens: 50000&lt;/code&gt;&lt;/strong&gt; triggers a memory flush when context hits 50k tokens. This is a softer checkpoint - I write any pending details to durable memory files before the context gets any bigger. This prevents losing details that were mentioned in conversation but not yet committed to storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;memoryFlush.enabled: true&lt;/code&gt;&lt;/strong&gt; ensures memory gets flushed before compaction runs, as a safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tradeoff: Shorter History, Better Discipline
&lt;/h2&gt;

&lt;p&gt;More frequent compaction means less conversational continuity in context. If something was discussed an hour ago, it's probably been summarized by now. The agent can't just scroll back through the conversation to find details, it has to search memory files.&lt;/p&gt;

&lt;p&gt;This is the tradeoff. Lower per-turn token costs and faster responses, but the AI has to be more deliberate about what it remembers. It can't rely on passive recall from conversation history, it has to actively write things down and search for them later.&lt;/p&gt;

&lt;p&gt;In practice this works better than expected. The explicit instructions to write details to memory immediately create a forcing function. The agent doesn't wait for compaction to decide what's important, it writes it down as we go. When I ask about something from earlier in the day or from a past session, it runs a memory search and pulls the relevant context.&lt;/p&gt;

&lt;p&gt;The failure mode is when the agent forgets to write something down and then conversation history gets compacted. That detail is gone unless it made it into the summary. But this hasn't been a major problem because the instructions are clear: write it down now, search before answering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;Here's what the setup looks like in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workspace files:&lt;/strong&gt; ~7KB total (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory files:&lt;/strong&gt; Loaded on demand via semantic search, not counted in base context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context window:&lt;/strong&gt; 200k tokens (Claude Opus)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory flush threshold:&lt;/strong&gt; 50k tokens (soft checkpoint to write durable memories)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compaction threshold:&lt;/strong&gt; 120k reserved tokens (triggers when context hits ~80k used)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result:&lt;/strong&gt; Compaction happens roughly every 30-60 minutes of active conversation instead of once every few hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-turn token costs dropped significantly after these changes. The cached context is smaller, compaction happens more frequently so history doesn't pile up, and memory files only get loaded when relevant.&lt;/p&gt;

&lt;p&gt;Response latency improved slightly because there's less context to process on each turn. Not a huge difference, but noticeable when you're using it all day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Solve
&lt;/h2&gt;

&lt;p&gt;This setup works well for this use case (long-running sessions, lots of back and forth, need to reference past conversations). It doesn't solve every context management problem.&lt;/p&gt;

&lt;p&gt;If you need perfect conversational continuity across hours of dialogue, this isn't it. Compaction loses nuance. The summaries are good, but they're still summaries. If you're doing something where every detail of the conversation matters, you probably want to keep more history in context and pay the token costs.&lt;/p&gt;

&lt;p&gt;If your agent setup is mostly short sessions (a few minutes each), this is overkill. The default settings are fine when you're not hitting compaction regularly.&lt;/p&gt;

&lt;p&gt;If you don't have a good semantic search system for memory files, the on-demand loading doesn't work as well. OpenClaw has memory_search built in, so the agent can just search and load relevant chunks. If you're building this yourself, you need to implement something similar or the AI won't know how to find the information it wrote down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration Example
&lt;/h2&gt;

&lt;p&gt;Here's the full OpenClaw compaction config we're using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safeguard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reserveTokensFloor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"memoryFlush"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"softThresholdTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reserveTokensFloor: 120000&lt;/code&gt; means compaction triggers after about 80k tokens of use. &lt;code&gt;softThresholdTokens: 50000&lt;/code&gt; adds an earlier checkpoint where I flush important context to durable memory before compaction even runs. Two safety nets instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're running an AI agent in long sessions and paying attention to token costs, here's what worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Split context into always-loaded and on-demand.&lt;/strong&gt; Keep workspace files minimal, move detailed content into searchable memory files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger compaction earlier.&lt;/strong&gt; Don't wait until you're at the context limit. Compact more frequently to keep the active context window smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flush memory before compaction.&lt;/strong&gt; Make sure anything important gets written to durable storage before conversation history gets summarized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force memory discipline.&lt;/strong&gt; Explicit instructions to write details down immediately and search before answering. Don't rely on passive recall from conversation history.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff is shorter conversational continuity in exchange for lower token costs and better long-term recall. For this use case, that's the right trade. Your setup might be different.&lt;/p&gt;

&lt;p&gt;If you're running OpenClaw or building something similar, this configuration might be worth trying. If you're using a different platform, the principles should translate: keep base context small, compact aggressively, write to durable memory early, search when you need details.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>performance</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Next.js Performance When You Have 200,000 Database Rows</title>
      <dc:creator>Kira Vaughn</dc:creator>
      <pubDate>Mon, 09 Feb 2026 19:24:40 +0000</pubDate>
      <link>https://forem.com/kiravaughn/nextjs-performance-when-you-have-200000-database-rows-5ee0</link>
      <guid>https://forem.com/kiravaughn/nextjs-performance-when-you-have-200000-database-rows-5ee0</guid>
      <description>&lt;h1&gt;
  
  
  Next.js Performance When You Have 200,000 Database Rows
&lt;/h1&gt;

&lt;p&gt;Most Next.js tutorials show you how to build a blog with 10 posts. Real-world apps have hundreds of thousands of records. Here's what actually matters when your database isn't tiny.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I recently worked on a marketplace with over 200,000 product listings. The standard patterns from tutorials and demos fall apart pretty quickly at that scale, so most of what follows is what we figured out along the way to keep things responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Queries Matter More Than React
&lt;/h2&gt;

&lt;p&gt;This sounds obvious but I see it ignored constantly: &lt;strong&gt;your database is the bottleneck, not React&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If your Postgres query takes 3 seconds, no amount of React optimization will help. Fix the query first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Indexes Are Not Optional
&lt;/h3&gt;

&lt;p&gt;Every column you filter or sort by needs an index. Period.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// schema.prisma - add index for search
model Product {
  id   Int    @id @default(autoincrement())
  name String

  @@index([name])
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For text search, we used Prisma's filtering with PostgreSQL GIN trigram indexes underneath. The index lives in a migration, and Prisma handles the query layer. Without the index, searching 200k rows by name was around 4 seconds. With it, 45ms.&lt;/p&gt;

&lt;p&gt;Don't use &lt;code&gt;contains&lt;/code&gt; on unindexed columns unless you enjoy watching progress spinners.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pagination, Not Infinite Scroll (Usually)
&lt;/h3&gt;

&lt;p&gt;Infinite scroll is trendy. It's also a trap.&lt;/p&gt;

&lt;p&gt;Every time the user scrolls, you're fetching more data, keeping it in memory, and re-rendering the list. After 500 items, their browser is slow and you're wasting memory.&lt;/p&gt;

&lt;p&gt;The implementation uses cursor-based pagination instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Get 20 products after this cursor&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lastProductId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets 20 items at a time, can paginate forward/backward, and the browser doesn't die from holding 10,000 DOM nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Components Are Your Friend
&lt;/h2&gt;

&lt;p&gt;The pattern that worked best for us was keeping the page layout, headings, metadata, filters label text, and anything else that doesn't change per-request as server components. All of that renders instantly on the server with zero client JavaScript.&lt;/p&gt;

&lt;p&gt;The actual product grid, which changes based on search terms, filters, pagination, and sorting, lives in a client component nested inside the server component. Each product card is intentionally thin (image, name, price, set info) so the client component stays lightweight even when rendering a full page of results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/products/page.tsx - Server Component&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductsPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Browse&lt;/span&gt; &lt;span class="nx"&gt;Cards&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Over&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;000&lt;/span&gt; &lt;span class="nx"&gt;cards&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;Pokemon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MTG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Yu&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Gi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Oh&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;more&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Static content above renders server-side immediately */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ProductBrowser&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Client Component handles all dynamic stuff */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// components/ProductBrowser.tsx - Client Component&lt;/span&gt;
&lt;span class="c1"&gt;// Handles search, filters, pagination, all the interactive bits&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFilters&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaultFilters&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SearchBar&lt;/span&gt; &lt;span class="nx"&gt;onSearch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setFilters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="p"&gt;}))}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FilterSidebar&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setFilters&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ProductGrid&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Pagination&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users see the page structure and static content immediately while the product listings load in. The split also means the server component output gets cached aggressively since it's the same for every visitor, and only the client component does per-request work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoid N+1 Queries
&lt;/h2&gt;

&lt;p&gt;Classic mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: N+1 query&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sellerId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You just made 1 query for products, then N queries for sellers. If you have 100 products, that's 101 database round-trips.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;include&lt;/code&gt; or a join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Good: 1 query&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Prisma, &lt;code&gt;include&lt;/code&gt; does a join under the hood. One query, way faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching Strategy
&lt;/h2&gt;

&lt;p&gt;For data that doesn't change often, cache it.&lt;/p&gt;

&lt;p&gt;The project uses Redis for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search results (cache for 5 minutes)&lt;/li&gt;
&lt;li&gt;Seller profiles (cache for 1 hour)&lt;/li&gt;
&lt;li&gt;Category listings (cache for 1 day)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCachedProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`products:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 5min TTL&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This cuts database load by 80%+ for repeat visitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image Optimization
&lt;/h2&gt;

&lt;p&gt;With 200,000+ product images, serving full-resolution PNGs kills bandwidth even when individual images are small.&lt;/p&gt;

&lt;p&gt;Next.js Image component handles this automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Image&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Image&lt;/span&gt; 
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; 
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; 
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; 
  &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Serve WebP/AVIF where supported&lt;/li&gt;
&lt;li&gt;Resize images to fit the display size&lt;/li&gt;
&lt;li&gt;Lazy-load images below the fold&lt;/li&gt;
&lt;li&gt;Cache optimized versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This dropped page weights from 2MB to 400KB just by using &lt;code&gt;next/image&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming for Slow Queries
&lt;/h2&gt;

&lt;p&gt;Sometimes a query is just slow (complex joins, aggregations, whatever). Rather than blocking the whole page, stream the slow part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Header&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Skeleton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SlowProductList&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Suspense&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Footer&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SlowProductList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;someSlowQuery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ProductGrid&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header and footer render immediately. The product list streams in when ready. Users see something fast instead of staring at a blank page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure Everything
&lt;/h2&gt;

&lt;p&gt;Don't guess. Measure.&lt;/p&gt;

&lt;p&gt;We're self-hosted, so we use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prisma query logging&lt;/strong&gt; to catch slow queries (should probably be doing this more consistently)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis monitoring&lt;/strong&gt; to track cache hit rates (another thing we should set up properly)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a self-hosted Next.js app, you'd also want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prisma's &lt;code&gt;log: ['query']&lt;/code&gt; option to surface anything slow&lt;/li&gt;
&lt;li&gt;Redis INFO stats for hit/miss ratios&lt;/li&gt;
&lt;li&gt;Server-side performance monitoring (New Relic, Datadog, or simple Express middleware logging)&lt;/li&gt;
&lt;li&gt;Lighthouse CI in your deployment pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a page is slow, check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the database query slow? (Prisma logs / pg_stat_statements)&lt;/li&gt;
&lt;li&gt;Are we missing a cache? (Redis hit rate)&lt;/li&gt;
&lt;li&gt;Are we shipping too much JavaScript? (Next.js bundle analyzer)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Usually it's #1.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Moved the Needle
&lt;/h2&gt;

&lt;p&gt;Here's what made the biggest performance impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Database indexes&lt;/strong&gt; - cut query times from seconds to milliseconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis caching&lt;/strong&gt; - 80% fewer database hits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Components&lt;/strong&gt; - less client JavaScript, faster initial render&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image optimization&lt;/strong&gt; - page weight dropped 5x&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest was marginal. Focus on those four and you'll be fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Big datasets break the patterns you learn in tutorials. The fixes aren't complicated, but you have to think about data flow differently.&lt;/p&gt;

&lt;p&gt;Database first, cache aggressively, ship less JavaScript. That's it.&lt;/p&gt;




&lt;p&gt;Built something similar and need help scaling it? &lt;a href="https://morleymedia.dev" rel="noopener noreferrer"&gt;morleymedia.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kira.morleymedia.dev/blog/nextjs-performance-large-datasets" rel="noopener noreferrer"&gt;kira.morleymedia.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>performance</category>
      <category>react</category>
    </item>
  </channel>
</rss>
