<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[My Sandbox]]></title><description><![CDATA[My Sandbox]]></description><link>https://demetrius.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a9bf84d69487b9d9604d9ef/996b1753-f371-4912-a431-479f34766936.png</url><title>My Sandbox</title><link>https://demetrius.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 07:01:25 GMT</lastBuildDate><atom:link href="https://demetrius.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Inside Carbon: The Engineering Behind a Searchable Clipboard]]></title><description><![CDATA[The full technical story: SQLite FTS5, prefix indexes, keyset pagination, covering indexes, EXPLAIN QUERY PLAN, virtualized rendering, and why benchmarking…]]></description><link>https://demetrius.hashnode.dev/building-carbon-the-tech-dive</link><guid isPermaLink="true">https://demetrius.hashnode.dev/building-carbon-the-tech-dive</guid><category><![CDATA[detailed-tech]]></category><category><![CDATA[backend-process]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Demetrius]]></dc:creator><pubDate>Thu, 08 Oct 2026 14:13:07 GMT</pubDate><content:encoded><![CDATA[<p>This is the technical companion to <em>I Built a Clipboard Manager After Windows Forgot What I Copied</em>. The first post explains why I built Carbon and what changed as its history grew. This one goes deeper into the decisions behind the rebuild: how Electron talks to SQLite, how indexes help Carbon find clips, why prefix search became slow, and how I kept a long timeline responsive.</p>
<p>I’ve tried to explain each term before leaning on it. If you’re a developer, the examples show the shape of the implementation. If you’re not, the explanations should still make clear what problem each part solves.</p>
<h2>The stack</h2>
<table>
<thead>
<tr>
<th>Part of the app</th>
<th>Technology</th>
</tr>
</thead>
<tbody><tr>
<td>Desktop shell</td>
<td>Electron 31</td>
</tr>
<tr>
<td>UI</td>
<td>React, TypeScript, Tailwind CSS, Framer Motion</td>
</tr>
<tr>
<td>Database</td>
<td>SQLite through <code>better-sqlite3</code></td>
</tr>
<tr>
<td>Full-text search</td>
<td>SQLite FTS5</td>
</tr>
<tr>
<td>Long-list rendering</td>
<td><code>react-window</code></td>
</tr>
<tr>
<td>Windows installer</td>
<td><code>electron-builder</code></td>
</tr>
</tbody></table>
<h2>How the app talks to the database</h2>
<p>Electron apps have a main process and one or more renderer processes. The main process manages application-level work. A renderer displays the web-based interface. Thinking of them as separate parts of the app helps explain an important Carbon design choice: the renderer does not talk directly to SQLite.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/c8526090-4653-4d9b-b9f6-8bf7b696bfec.png" alt="" style="display:block;margin:0 auto" />

<p>Instead, the UI asks for data through Electron’s inter-process communication system, usually shortened to IPC. The main process receives the request, runs the database query, and sends back the result. The preload script exposes only the app functions the UI needs.</p>
<p>The example below is shortened to show the boundary:</p>
<pre><code class="language-typescript">// Main process
ipcMain.handle('clips:getPage', (_event, query: ClipQuery) =&gt; getPage(query))
ipcMain.handle('clips:search', (_event, query: SearchQuery) =&gt; searchPage(query))

// Preload process
contextBridge.exposeInMainWorld('carbon', {
  getPage: (query: ClipQuery) =&gt; ipcRenderer.invoke('clips:getPage', query),
  searchClips: (query: SearchQuery) =&gt; ipcRenderer.invoke('clips:search', query),
})
</code></pre>
<p>This keeps the database connection in the main process and gives the renderer serializable clip data, not a raw database handle. New clips can also be pushed to the interface as events instead of having the UI repeatedly ask whether anything changed.</p>
<h2>Version 1: a file was enough, until it wasn’t</h2>
<p>The first version stored one JSON object per line in <code>clips.jsonl</code>:</p>
<pre><code class="language-json">{"id":1,"type":"text","text":"hello world","createdAt":1688000000000,"hash":"abc123"}
{"id":2,"type":"image","imageFile":"screenshot-1.png","createdAt":1688000100000,"hash":"def456"}
</code></pre>
<p>On launch, Carbon loaded the whole file into memory. Search checked every item. Adding, pinning, or deleting a clip rewrote the file.</p>
<p>That design was easy to understand and perfectly usable with a small history. Its weakness was that the amount of work grew with the number of clips. Loading and searching required looking at the collection as a whole, and even a small edit involved rewriting the whole file.</p>
<h2>The SQLite schema</h2>
<p>Moving to SQLite was not simply putting the old JSON objects somewhere else. The schema determines what data can be queried efficiently and what constraints the database can enforce.</p>
<pre><code class="language-sql">CREATE TABLE clips (
  id         INTEGER PRIMARY KEY,
  type       TEXT NOT NULL,
  text       TEXT,
  image_file TEXT,
  width      INTEGER,
  height     INTEGER,
  created_at INTEGER NOT NULL,
  day        TEXT NOT NULL,
  hash       TEXT NOT NULL UNIQUE,
  pinned     INTEGER NOT NULL DEFAULT 0,
  copy_count INTEGER NOT NULL DEFAULT 1
);
</code></pre>
<p>A few choices are worth explaining:</p>
<ul>
<li><p><code>created_at</code> <strong>is an integer timestamp.</strong> Carbon stores epoch milliseconds and uses the value for ordering. The database can compare numbers directly.</p>
</li>
<li><p><code>day</code> <strong>is stored even though it can be derived.</strong> It supports grouping clips by day and calculating daily activity without repeating date conversion in every query.</p>
</li>
<li><p><code>hash</code> <strong>is unique.</strong> When Carbon sees the same content again, it can recognize it instead of creating another row. It updates the timestamp and increments <code>copy_count</code> so the clip resurfaces in the timeline.</p>
</li>
<li><p><strong>Text and images have different fields.</strong> Text clips use <code>text</code>; image clips refer to a stored image file and can include dimensions.</p>
</li>
</ul>
<h2>Indexes: a way to find the right rows</h2>
<p>A database index is a separate structure that helps the database find or order rows without examining the entire table. A familiar analogy is the index at the back of a textbook. To find pages about a topic, you use the alphabetical index instead of reading every page.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/704d8f08-be7e-407c-864e-611b51e20bcc.png" alt="" style="display:block;margin:0 auto" />

<p>SQLite commonly uses B-tree indexes. A B-tree keeps indexed values in sorted order and is structured so the database can narrow its search efficiently. The index also points back to the corresponding table rows.</p>
<p>For a timeline of 100,000 clips, Carbon may need only the newest 60 unpinned clips. Without a suitable index, SQLite might need to inspect many rows and sort them before it can return those 60. An index designed for the query can help it find the right section and read entries in the requested order.</p>
<p>Carbon’s timeline queries filter by pinned status, may filter by type, and sort newest first. The indexes reflect those patterns:</p>
<pre><code class="language-sql">CREATE INDEX idx_clips_pinned_created
  ON clips(pinned, created_at DESC, id DESC);

CREATE INDEX idx_clips_type_pinned_created
  ON clips(type, pinned, created_at DESC, id DESC);
</code></pre>
<p>The order of columns matters. A multi-column B-tree is ordered by its first column, then by its second within groups of the first, and so on. With <code>pinned</code> first, SQLite can locate the unpinned group. Within that group, the entries are ordered by timestamp and ID. The ID breaks ties when two clips share a timestamp.</p>
<p>An index does have costs. It uses disk space, and inserts or updates must keep it up to date. That is why I chose indexes based on Carbon’s actual query patterns rather than indexing every column.</p>
<h3>Checking whether an index is useful</h3>
<p>SQLite includes <code>EXPLAIN QUERY PLAN</code>, which describes how it intends to run a query:</p>
<pre><code class="language-sql">EXPLAIN QUERY PLAN
SELECT * FROM clips
WHERE pinned = 0
ORDER BY created_at DESC, id DESC
LIMIT 60;
</code></pre>
<p>The output can show whether SQLite uses an index or scans the table, and whether it needs an extra temporary sort. That matters because creating an index does not guarantee that every query will use it. I used query plans to check what SQLite was doing after changing indexes, then timed the operations to see how the application behaved.</p>
<p>Carbon also has a partial index for repeated clips:</p>
<pre><code class="language-sql">CREATE INDEX idx_clips_copy_count
  ON clips(copy_count DESC)
  WHERE copy_count &gt; 1;
</code></pre>
<p>A partial index includes only rows that satisfy its <code>WHERE</code> condition. Since the dashboard is interested in clips copied more than once, this avoids putting single-copy clips into that particular index. The benefit depends on the data and query, so it is something to measure rather than assume.</p>
<h2>FTS5: finding words inside clips</h2>
<p>A regular B-tree index is good for structured values such as timestamps, pinned status, or clip type. It is not designed to find a word anywhere inside a large body of text. Carbon uses SQLite’s FTS5 extension for full-text search.</p>
<p>FTS5 maintains an <strong>inverted index</strong>. A regular table associates each row with its values. An inverted index works in the other direction: it associates each word with the rows that contain it.</p>
<pre><code class="language-text">Word      Clips containing that word
error     1, 3, 47, 891, ...
meeting   2, 56, ...
</code></pre>
<p>When someone searches for a word, the index gives FTS5 a set of matching clips to work with. That avoids starting each search by reading the text of every clip. It does not make every search take the same amount of time. Common words, broad queries, and additional filtering can still affect the work involved.</p>
<p>Carbon uses an external-content FTS table. The main <code>clips</code> table stores the clip text, while FTS5 stores the search index and refers back to the main rows:</p>
<pre><code class="language-sql">CREATE VIRTUAL TABLE clips_fts USING fts5(
  text,
  content='clips',
  content_rowid='id',
  tokenize='unicode61',
  prefix='2 3 4 5 6 7 8 9 10'
);
</code></pre>
<p>Triggers keep the FTS index synchronized when clips are inserted, updated, or deleted. For example, an insert trigger adds the new row to the search index:</p>
<pre><code class="language-sql">CREATE TRIGGER clips_ai AFTER INSERT ON clips BEGIN
  INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
END;
</code></pre>
<p>The update and delete triggers remove the old indexed text where necessary and add the new text for updates.</p>
<h3>The prefix-search problem</h3>
<p>Carbon supports prefix matching. If someone types <code>err</code>, a search can match terms such as <code>error</code>, <code>errand</code>, or <code>errno</code>. This is different from searching for one complete word.</p>
<p>I found a performance problem by comparing common-word and rare-word searches on a large synthetic dataset. The common search took much longer. FTS5 supports prefix indexes, which let it prepare lookups for the beginning of terms instead of doing the same prefix work during each query. I configured prefix lengths from 2 through 10:</p>
<pre><code class="language-sql">prefix='2 3 4 5 6 7 8 9 10'
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/e5610320-4fdf-43e4-a909-952b2383d078.png" alt="" style="display:block;margin:0 auto" />

<p>There is a trade-off. Prefix indexes use additional storage and require extra index work when clips are added or changed. In Carbon’s test, the improvement to prefix search was worth that cost. The common-word case that took 617 milliseconds took 3 milliseconds after the change.</p>
<p>That number belongs to that query and test dataset. It does not mean every search is 200 times faster. The useful lesson was that prefix searches behaved differently from the searches I had initially tested, and the benchmark helped me find the right configuration for Carbon.</p>
<h3>Updating search indexes for existing users</h3>
<p>A new FTS table definition does not change a table that was already created in an existing database. Carbon checks the stored definition and rebuilds the FTS table if the prefix configuration is missing. It then fills the index again from the clips table.</p>
<p>This is a one-time migration for a database that needs it. It matters because an optimization that only works for fresh installations is not a complete upgrade for people who already have clipboard history.</p>
<h2>Keyset pagination: fetching the next part of the timeline</h2>
<p>Carbon does not load every clip when the timeline opens. It fetches 60 at a time. The basic idea is pagination: divide a long list into smaller batches.</p>
<p>One common method uses <code>LIMIT</code> and <code>OFFSET</code>:</p>
<pre><code class="language-sql">SELECT * FROM clips
ORDER BY created_at DESC, id DESC
LIMIT 60 OFFSET 5000;
</code></pre>
<p>This asks for 60 rows after skipping 5,000. The deeper the requested page, the more rows the database may have to pass over before reaching it. That is wasteful for a timeline where the user usually asks for the next batch, not for an arbitrary page number.</p>
<p>Carbon uses <strong>keyset pagination</strong>. The last row from the current page becomes a cursor. The next query asks for clips older than that timestamp and ID:</p>
<pre><code class="language-sql">SELECT * FROM clips
WHERE pinned = 0
  AND (created_at, id) &lt; (@cursorCreatedAt, @cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 60;
</code></pre>
<p>The timestamp and ID together create a stable position in the ordered list. Because those values match the index ordering, SQLite can continue from that position rather than skipping a growing number of earlier rows. The exact cost still depends on the query and data, but this avoids the growing skip work associated with deep offsets.</p>
<p>The trade-off is that keyset pagination is naturally suited to continuing from a known item. It does not make jumping directly to page 347 convenient. For a scrolling clipboard timeline, that is a reasonable trade.</p>
<h3>Handling responses that arrive out of order</h3>
<p>There is also a UI problem. A user might type a new search before the previous search request has finished. If the older response arrives last, it must not overwrite the newer results.</p>
<p>Carbon uses a generation counter. Each time the query changes, the counter advances. A response is applied only if it belongs to the current generation:</p>
<pre><code class="language-typescript">const generation = ++generationRef.current
const page = await fetchPage(query)

if (generation !== generationRef.current) return
setItems(page.items)
</code></pre>
<p>This small check prevents a stale response from replacing results for a newer search or filter.</p>
<h2>Virtualization: rendering the visible part of the list</h2>
<p>Fetching 60 clips at a time reduces how much data arrives in each request. But a user can keep scrolling, and the interface may accumulate many loaded clips. Even if the database is fast, rendering a very large number of UI elements can make the page sluggish.</p>
<p>A browser maintains a document tree called the DOM. Each card, text element, and control contributes nodes to that tree. Off-screen elements still exist in the DOM, use memory, and can take part in layout and rendering work.</p>
<p>Carbon uses <code>react-window</code> to render only the rows around the visible part of the timeline, plus a small buffer. As the user scrolls, rows that leave the rendered area are removed and new ones are rendered. This keeps the number of mounted elements bounded rather than growing with every clip loaded during a session.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/a446e8d0-3675-44bf-b7fa-1e13db705a92.png" alt="" style="display:block;margin:0 auto" />

<p>Carbon’s timeline is not a flat list of identical cards. It includes date headers, clip cards with different heights, and a row that can trigger loading the next page. I represent those as a flat sequence of row types for the virtualized list:</p>
<pre><code class="language-typescript">type TimelineRow =
  | { kind: 'header'; key: string; title: string }
  | { kind: 'clip'; key: string; clip: ClipRecord }
  | { kind: 'sentinel'; key: string }
</code></pre>
<p>The sentinel is a special row at the end. When it enters the rendered area, it can trigger <code>loadMore()</code>. Clip cards can have different heights, so their sizes need to be measured and accounted for. Pagination and virtualization solve different parts of the problem: pagination controls how much data has been fetched, while virtualization controls how much of that data is mounted in the interface.</p>
<h2>SQLite settings: tuning how the database works</h2>
<p>Carbon configures a few SQLite settings when it opens the database:</p>
<pre><code class="language-typescript">function createSchema(): void {
  db.pragma('journal_mode = WAL')
  db.pragma('synchronous = NORMAL')
  db.pragma('cache_size = -32000')
  db.pragma('temp_store = MEMORY')
}
</code></pre>
<p>These settings affect journaling, durability, caching, and temporary work. They are not substitutes for suitable queries and indexes, and their impact depends on the workload.</p>
<p><strong>Write-ahead logging (WAL)</strong> changes how SQLite records writes. It lets readers continue reading the database while changes are written to a separate log, rather than making readers wait for every write to finish. This is useful for Carbon because the app can be reading history while new clips are being recorded.</p>
<p><code>synchronous = NORMAL</code> is a durability and performance choice used here with WAL mode. It reduces how often SQLite asks the operating system to force data to storage. A sudden machine power loss can put the most recent committed changes at risk, although an ordinary app crash is not the same as losing power. This setting is a deliberate trade-off, not a setting to copy without considering the data an app stores.</p>
<p><code>cache_size = -32000</code> configures SQLite’s page cache in kibibytes when the value is negative, giving it roughly 32 MiB for cached database pages. Repeated reads may then use pages already in memory rather than fetching them again from disk.</p>
<p><code>temp_store = MEMORY</code> asks SQLite to keep temporary structures in memory instead of temporary files where possible. Queries that need temporary sorting or grouping can use such structures. The setting does not mean every query becomes faster, and memory use is part of the trade-off.</p>
<h2>Measuring the changes</h2>
<p>I generated synthetic datasets with up to one million clips. To make search tests challenging, I deliberately skewed the vocabulary so some words appeared in many clips. I measured common-word and rare-word searches, opening the app, scrolling, and date filtering.</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Search a word matching 60% of all rows</td>
<td>617 ms</td>
<td><strong>3 ms</strong></td>
</tr>
<tr>
<td>Search the most common word in the corpus</td>
<td>471 ms</td>
<td><strong>4 ms</strong></td>
</tr>
<tr>
<td>Search a rare word</td>
<td>112 ms</td>
<td><strong>6 ms</strong></td>
</tr>
<tr>
<td>Search on a 300,000-row dataset, earlier test pass</td>
<td>355 to 669 ms</td>
<td>20 to 23 ms</td>
</tr>
</tbody></table>
<p>These are measurements from my test setup, not universal performance guarantees. The million-clip case is deliberately much larger than most people are likely to need. It helped expose a slow search case that was not obvious in smaller tests.</p>
<p>I used <code>EXPLAIN QUERY PLAN</code> to see how SQLite planned the queries, and timed the application operations to check the end-to-end result. The plan helps explain whether an index is used. Timing tells me how the operation behaves in the test. Looking at both is more useful than guessing from either one alone.</p>
<h2>Querying the dashboard</h2>
<p>Once Carbon had a database, I could ask questions about my own clipboard history. The dashboard shows totals and activity patterns. Rather than loading all clip text into the interface, Carbon can ask SQLite for aggregate results.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/f47e6f79-8797-4225-9b36-d8317c4f06f1.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/b7884e9a-eb96-4c78-b5ba-aab6f2a45bb2.png" alt="" style="display:block;margin:0 auto" />

<p>For example, these separate queries each calculate one statistic:</p>
<pre><code class="language-sql">SELECT COUNT(*) FROM clips;
SELECT COUNT(*) FROM clips WHERE type = 'text';
SELECT COUNT(*) FROM clips WHERE type = 'image';
SELECT COUNT(*) FROM clips WHERE pinned = 1;
</code></pre>
<p>They can be combined into one aggregate query:</p>
<pre><code class="language-sql">SELECT COUNT(*) AS total,
       SUM(type = 'text') AS text_count,
       SUM(type = 'image') AS image_count,
       SUM(pinned = 1) AS pinned_count,
       MIN(day) AS first_day,
       MAX(day) AS last_day
FROM clips;
</code></pre>
<p>In SQLite, a true comparison evaluates as 1 and a false comparison as 0, so summing a condition counts the rows where it is true. The query can calculate several summary values in one pass over the table. The dashboard also groups activity by day and calculates repeated clips from <code>copy_count</code>.</p>
<h2>Packaging the app</h2>
<p>The final app needs to work outside my development environment. <code>electron-builder</code> packages the Electron runtime, the UI, dependencies, and the compiled <code>better-sqlite3</code> native module into a Windows installer.</p>
<p>Native modules require special handling in Electron packages. Carbon’s installer configuration unpacks the native module from the ASAR archive, because a compiled native module cannot be loaded directly from inside that archive. The installer can then create shortcuts and install the app so it runs from the system tray.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/daeed9de-4298-496b-b652-4b5818208101.png" alt="" style="display:block;margin:0 auto" />

<h2>What I learned</h2>
<p>The rebuild taught me that performance is not one switch. The database needed indexes that fit the queries. Full-text search needed configuration that fit prefix searches. Pagination needed to avoid skipping more and more rows. The interface needed to avoid rendering everything it had loaded.</p>
<p>It also taught me to be specific about what a benchmark proves. The improvement from 617 milliseconds to 3 milliseconds is meaningful for the query and dataset I measured. It helped me find and fix a real bottleneck. It does not mean every search or every user will see that same improvement.</p>
<p>Carbon began as a small personal tool. Testing it at a scale I did not expect to need helped me understand where it was doing unnecessary work and which changes actually helped.</p>
<p>The companion post, <em>I Built a Clipboard Manager After Windows Forgot What I Copied</em>, tells the shorter story of the original problem and the rebuild.</p>
]]></content:encoded></item><item><title><![CDATA[I Built a Clipboard Manager Because Windows’ History Wasn’t Enough]]></title><description><![CDATA[How a frustrating clipboard limit led me to build Carbon, a desktop app that handles 1,000,000 rows with sub-3ms latency.]]></description><link>https://demetrius.hashnode.dev/building-my-own-clipboard-app</link><guid isPermaLink="true">https://demetrius.hashnode.dev/building-my-own-clipboard-app</guid><category><![CDATA[Data Engineering  Software Engineering  Productivity  Web Development  Programming  SQLite]]></category><dc:creator><![CDATA[Demetrius]]></dc:creator><pubDate>Sat, 05 Sep 2026 14:40:17 GMT</pubDate><content:encoded><![CDATA[<p>I copy things all day: a command from a terminal, a link from Slack, a value from a spreadsheet. Most of the time, I don’t think about it. Then I need something I copied a few minutes ago, and it’s gone.</p>
<p>Windows clipboard history helps, but it keeps only the last 25 items. On a busy day, I can fill that in about fifteen minutes. There’s no search, and the history clears when I restart. If I need an old error message, I have to find it again where I copied it from.</p>
<p>That was the small frustration behind Carbon. I wanted a clipboard history I could search and keep locally.</p>
<h3>What Carbon does</h3>
<p>Carbon runs in the Windows system tray and saves the text and images I copy. I can browse clips by day, search them, pin the ones I use often, and open the app with a keyboard shortcut. I also added a dashboard with usage stats, including my busiest hour, capture streaks, and most repeated clips.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/e6b4a1dd-61ef-43b8-8c6d-5a44ddddac38.png" alt="Carbon's main timeline view with search, filters, and date filtering" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/6a9bf84d69487b9d9604d9ef/c488e8ac-3dcc-40d4-b413-d17c0262fd08.png" alt="Carbon's dashboard with total clips, storage, activity, and usage habits" style="display:block;margin:0 auto" />

<p>The dashboard started as a curiosity. Once I had a history I could query, I wanted to know what my own clipboard use looked like. Apparently, I copy the most on Wednesdays at 7 PM.</p>
<h3>When the simple version stopped being simple</h3>
<p>The first version stored each copied item as a line in a <code>clips.jsonl</code> file. When Carbon opened, it loaded the whole file into memory. Search checked every clip, and pinning or deleting an item meant rewriting the file.</p>
<p>With a few hundred clips, that approach was fine. After a few months, I had thousands. Carbon took longer to open, scrolling felt choppy, and searches took noticeably longer.</p>
<p>Nothing had suddenly broken. The app was just doing more work every time the history grew. It loaded everything to show a small part of the history, checked everything to find a search result, and rewrote everything to change one clip. That was the point where I needed to change the design, not just tweak the code.</p>
<h3>The move to SQLite</h3>
<p>I moved Carbon’s history to <strong>SQLite</strong>, a database stored in a local file. The app needed to search and filter data on the same machine, and SQLite let me do that without running a separate server. The move gave me better tools, but I still had to use them well.</p>
<p>I added indexes for the ways Carbon retrieves clips, such as newest first, pinned clips, and clip type. For text search, I used SQLite’s <strong>FTS5</strong> full-text search. It maintains an index of words in the clips, so a search does not have to start by checking every clip’s text.</p>
<p>The surprise came when I stress-tested search with one million fake clips. Searching for a word that appeared in 60% of them took 617 milliseconds. A rare-word search was much faster. That difference told me I had a slow case I had not accounted for.</p>
<p>Carbon supports prefix searches, such as finding words that begin with <code>err</code>. I configured FTS5 to build prefix lookups ahead of time. In the same test, the slow search dropped to <strong>3 milliseconds</strong>. That is about 200 times faster for this particular query and dataset, not a promise about every search.</p>
<table>
<thead>
<tr>
<th>Test scenario</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Search a word matching 60% of the test rows</td>
<td>617 ms</td>
<td><strong>3 ms</strong></td>
</tr>
<tr>
<td>Search the most common word in the test corpus</td>
<td>471 ms</td>
<td><strong>4 ms</strong></td>
</tr>
<tr>
<td>Search a rare word</td>
<td>112 ms</td>
<td><strong>6 ms</strong></td>
</tr>
</tbody></table>
<p>The database was only part of the problem. Carbon used to load the whole history at startup. It now loads 60 clips at a time and fetches another batch as I scroll. I used <strong>keyset pagination</strong>, which asks for the next batch after the last clip shown instead of skipping an ever-growing number of earlier rows.</p>
<p>The interface had a separate bottleneck. Rendering thousands of clip elements at once could make scrolling sluggish even if the database returned results quickly. <strong>Virtualization</strong> limits the number of clip elements rendered to the items around the visible part of the list.</p>
<p>I tested with datasets of up to a million clips, including vocabularies that made search harder. I measured searches, date filtering, opening, and scrolling. SQLite’s <code>EXPLAIN QUERY PLAN</code> also helped me check whether a query was using an index or scanning the table. The timings told me whether the app felt faster; the query plan helped explain what the database was doing.</p>
<h3>Making it feel finished</h3>
<p>Once the core functionality was in place, I spent time on the interface. I used <strong>Antigravity</strong> to help polish it, including tightening spacing and reworking a date filter that had become cramped. I still had to decide what felt right for Carbon, but the tool helped me work through the visual refinements.</p>
<p>I used <strong>electron-builder</strong> to package Carbon as a Windows installer. That meant I could install it and run it from the system tray instead of starting it with <code>npm run dev</code>.</p>
<h3>What changed for me</h3>
<p>The first version wasn’t a mistake. It was a reasonable way to get started, and it worked for the amount of data I had. I underestimated how different the app would feel once that data grew.</p>
<p>The million-clip test was intentionally larger than I expect most people’s histories to become. It helped reveal a slowdown that I couldn’t see in smaller tests. I learned to measure the slow case, investigate what was happening, make a change, and measure again.</p>
<p>Carbon began as a way to find things I’d copied earlier. Building it taught me that a design can be right for getting started and still need to change as the data grows.</p>
<p><em>Carbon is a personal project, a Windows clipboard manager with a searchable local history. I built it because I got tired of having to track down things I had already copied.</em></p>
<p>The companion post, <em>Inside Carbon: The Engineering Behind a Searchable Clipboard</em>, goes deeper into the schema, search, pagination, and rendering choices.</p>
]]></content:encoded></item></channel></rss>