<?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: Hamza Saleem</title>
    <description>The latest articles on Forem by Hamza Saleem (@hamzasaleem).</description>
    <link>https://forem.com/hamzasaleem</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%2F1615232%2F94782547-8391-4765-9d25-6afb39883852.jpeg</url>
      <title>Forem: Hamza Saleem</title>
      <link>https://forem.com/hamzasaleem</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hamzasaleem"/>
    <language>en</language>
    <item>
      <title>Scaling to 70 Modules: Building a Web, Mobile, and API backend on one Convex deployment</title>
      <dc:creator>Hamza Saleem</dc:creator>
      <pubDate>Wed, 21 Jan 2026 06:31:53 +0000</pubDate>
      <link>https://forem.com/convexchampions/scaling-to-70-modules-building-a-web-mobile-and-api-backend-on-one-convex-deployment-3pcg</link>
      <guid>https://forem.com/convexchampions/scaling-to-70-modules-building-a-web-mobile-and-api-backend-on-one-convex-deployment-3pcg</guid>
      <description>&lt;h2&gt;
  
  
  Production Convex: What 70 Modules Looks Like
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What I learned building one Convex backend for web, mobile, and API&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;When I started building &lt;a href="https://clientcommander.com" rel="noopener noreferrer"&gt;Client Commander&lt;/a&gt;, I had one Next.js app and maybe ten tables. A year later, it's serving a web dashboard, a mobile app, a background sync service, and a full REST API. 50+ tables. 70+ modules. Same deployment.&lt;/p&gt;

&lt;p&gt;I didn't plan for any of that. I just kept building, and the architecture held up. That surprised me.&lt;/p&gt;

&lt;p&gt;Here's how it happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fclientcommander.com%2Fss.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fclientcommander.com%2Fss.png" alt="Client Commander Dashboard" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://clientcommander.com" rel="noopener noreferrer"&gt;Client Commander&lt;/a&gt; is a multi-tenant CRM. Companies sign up, add their team, manage contacts and deals. Nothing revolutionary about the domain — but the technical requirements add up fast: permissions, real-time sync, mobile, API access, background jobs.&lt;/p&gt;

&lt;p&gt;Quick terminology so the rest makes sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Agents&lt;/strong&gt;: The users (employees) using the system.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Contacts&lt;/strong&gt;: The people/customers being tracked.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Companies&lt;/strong&gt;: The tenants (customers of the SaaS).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstack.convex.dev%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fcdn.sanity.io%252Fimages%252Fts10onj4%252Fproduction%252F59a918c528e8b5e9092eeee50771ef4f805918a8-695x1142.png%26w%3D1920%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstack.convex.dev%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fcdn.sanity.io%252Fimages%252Fts10onj4%252Fproduction%252F59a918c528e8b5e9092eeee50771ef4f805918a8-695x1142.png%26w%3D1920%26q%3D75" alt="Architecture Overview" width="695" height="1142"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Permissions That Update Themselves
&lt;/h2&gt;

&lt;p&gt;Change someone's role, and their dashboard updates in real-time. They see different data instantly — no refresh, no logout, no waiting.&lt;/p&gt;

&lt;p&gt;I didn't build that. I just wrote a normal permission check at the top of my queries, and it worked.&lt;/p&gt;

&lt;p&gt;Took me a while to understand why. Convex queries are subscriptions, not one-off requests. They keep running — every time the underlying data changes, the query re-evaluates. So my permission check runs again when contacts change. It runs again when the user's role changes. Same code, always current.&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;}&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;getAuthContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contacts.view&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contacts&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="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_company&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;companyId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding new permission levels was just config after that. Owner sees everything. Team Leader sees their team. Agent sees only their own contacts. New roles? Add them to the config. Queries stay the same.&lt;/p&gt;

&lt;p&gt;The part that impressed me: Convex tracks which data each query actually touches. My query reads the user's role, so the platform knows that query depends on that role record. Change the role, and only queries that care about it re-run — not everything. I didn't set up subscriptions or configure channels. It figured that out from the code.&lt;/p&gt;

&lt;p&gt;With most backends, you'd need WebSocket infrastructure, cache invalidation, push logic — a whole thing. Here it's just... the default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding Mobile Without Adding Backend
&lt;/h2&gt;

&lt;p&gt;We shipped a mobile app. Zero new backend code.&lt;/p&gt;

&lt;p&gt;But honestly, the time savings wasn't the main thing. The main thing was this: we stopped discovering "mobile is out of sync" from user bug reports. We started discovering it before the code compiles.&lt;/p&gt;

&lt;p&gt;The Expo app imports the exact same API as the Next.js app. Same queries, same mutations, same types. Fix a bug in a query? Fixed on both. Change the schema? Both apps break until they handle it — during development, not after users complain.&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;// Same import, whether you're in Next.js or Expo&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;api&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="s2"&gt;@workspace/backend/convex/_generated/api&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;contacts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;list&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;createContact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Convex generates typed APIs from your schema. That generated code is the contract. Write a query, and it creates types for the arguments and return value. Call it from React — web or mobile, doesn't matter — you get autocomplete and type checking.&lt;/p&gt;

&lt;p&gt;The monorepo makes it work. Backend is a shared package. Both apps depend on it. Schema is the single source of truth.&lt;/p&gt;

&lt;p&gt;Add a field? Both apps get type errors. Rename a query? TypeScript shows you every callsite that needs updating — across platforms, in one compile. I've caught so many things this way that would've been production bugs otherwise.&lt;/p&gt;

&lt;p&gt;Most cross-platform setups have duplicated types, separate clients, manual syncing. Here, the generated API handles sync. Change the source, types change everywhere. No process to remember. It just breaks if you forget.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-Time for Users, REST for Everything Else
&lt;/h2&gt;

&lt;p&gt;Real-time is great for dashboards. But external integrations don't speak WebSocket. They want REST. Webhooks need HTTP endpoints.&lt;/p&gt;

&lt;p&gt;So we added a REST API — 40+ endpoints. No separate service. The HTTP layer just authenticates, rate limits, then calls the same functions that power the UI.&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="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/contacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;httpAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;auth&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;verifyApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&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;contacts&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companyId&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&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;contacts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&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;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice part: &lt;code&gt;ctx.runQuery&lt;/code&gt; inside an HTTP action runs the exact same code as the real-time subscriptions. I'm not maintaining two implementations — the REST endpoint is just a thin wrapper around stuff that already exists.&lt;/p&gt;

&lt;p&gt;Webhooks work the same way. Payment provider sends a POST, I verify the signature, call a mutation. Same mutation the UI calls. One code path.&lt;/p&gt;

&lt;p&gt;No separate API server. No connection pooling headaches. HTTP routes deploy with everything else. We went from "we need an API" to live endpoints in a day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Workflows That Outlive Deployments
&lt;/h2&gt;

&lt;p&gt;We have workflows that wait three days before executing the next step. Some wait a week.&lt;/p&gt;

&lt;p&gt;Here's the thing: I don't lose them when I deploy. I don't wake up to half-finished workflows. I don't build state machines to track what step we're on.&lt;/p&gt;

&lt;p&gt;They just continue. Server restarts, new deployment happens, doesn't matter. The delay finishes, the next step runs, picks up exactly where it left off.&lt;/p&gt;

&lt;p&gt;If you've ever used &lt;code&gt;scheduler.runAfter&lt;/code&gt;, you know it works for one-off delayed functions. But when you need a chain — wait, then do X, then wait again, then do Y — suddenly you're managing state. What if step 2 fails? How do you know step 1 finished? How do you retry?&lt;/p&gt;

&lt;p&gt;The workflow component handles that. Each step gets recorded. If something restarts mid-execution, it replays from where it stopped — skipping steps that already ran.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;myWorkflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&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="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markOnboardingStarted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sendFollowUp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; 
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;runAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// 3 days&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkEngagement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&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;Each step is checkpointed. Server restarts after step 2 is scheduled? Fine. It recovers and picks up where it was.&lt;/p&gt;

&lt;p&gt;I build automations that span weeks now without worrying about them. Onboarding sequences, trial expirations — they just run. No job queue to maintain. No polling for stuck jobs. No "what state is this in?" debugging at 2am.&lt;/p&gt;




&lt;h2&gt;
  
  
  Search Without the Infrastructure
&lt;/h2&gt;

&lt;p&gt;We needed search. Full-text, on contacts. My first thought was "okay, time to figure out Elasticsearch."&lt;/p&gt;

&lt;p&gt;Nope. Three lines in the schema.&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="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;companies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;span class="nf"&gt;searchIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_name&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="na"&gt;searchField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fullName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;filterFields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;companyId&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Search works. Filters by company. Ranks by relevance. Deploy, it's live.&lt;/p&gt;

&lt;p&gt;Same pattern kept repeating. Need to find contacts by phone number? Add an index. Look up deals by stage? Index. Sort by next task due date? Store it denormalized and index it.&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="nx"&gt;contactPhones&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;contactId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contacts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_value&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;nextTaskDueAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&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;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_company_nextTask&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;companyId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nextTaskDueAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normally you'd be deciding: which search service, how to sync data, how to handle the lag between your database and search index. Here, search indexes update transactionally with your data. No sync. No eventual consistency weirdness.&lt;/p&gt;

&lt;p&gt;The trade-off: I denormalize more than I would elsewhere. &lt;code&gt;fullName&lt;/code&gt; gets computed and stored. &lt;code&gt;nextTaskDueAt&lt;/code&gt; gets copied from tasks to contacts. Writes get a bit messier. But queries stay fast, and I don't manage infrastructure.&lt;/p&gt;

&lt;p&gt;50+ tables, dozens of indexes. Every single one was a schema change, not a project.&lt;/p&gt;




&lt;p&gt;That's what 70 modules looks like. One deployment. Web, mobile, REST, all hitting the same backend. Permissions that update in real-time. Types that catch drift before production. Workflows that survive restarts. Search that's three lines.&lt;/p&gt;

&lt;p&gt;I didn't do anything clever to make this work. I just kept building, and the platform didn't get in the way.&lt;/p&gt;

&lt;p&gt;If you're thinking about using Convex for something real — this is what happens when you do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Hamza Saleem is the founder of &lt;a href="https://clientcommander.com" rel="noopener noreferrer"&gt;Client Commander&lt;/a&gt; and a Convex Champion. Previously: &lt;a href="https://stack.convex.dev/keeping-real-time-users-in-sync-convex" rel="noopener noreferrer"&gt;Keeping Users in Sync with Convex&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>convex</category>
      <category>news</category>
      <category>discuss</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Easily the best Subscription Tracker for me [Dockerized] [Local] [No Cloud]</title>
      <dc:creator>Hamza Saleem</dc:creator>
      <pubDate>Sun, 16 Mar 2025 12:34:08 +0000</pubDate>
      <link>https://forem.com/hamzasaleem/easily-the-best-subscription-tracker-for-me-dockerized-local-no-cloud-bh4</link>
      <guid>https://forem.com/hamzasaleem/easily-the-best-subscription-tracker-for-me-dockerized-local-no-cloud-bh4</guid>
      <description>&lt;p&gt;I built Subra out of frustration with my own subscription mess.... forgotten trials, surprise charges, and that constant "wait, how much am I spending?" feeling.&lt;/p&gt;

&lt;p&gt;It's straightforward: Subra tracks your subscriptions in one clean dashboard, calculates your total spend, and reminds you before payments hit your card. No fluff, no complex features you don't need.&lt;/p&gt;

&lt;p&gt;The core tools are free to use:&lt;br&gt;
Subscription calculator&lt;br&gt;
Family plan cost splitter&lt;br&gt;
Spending analyzer&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://subra.app" rel="noopener noreferrer"&gt;https://subra.app&lt;/a&gt;. No signup needed for the basic tools.&lt;/p&gt;

&lt;p&gt;If you would prefer to host on your own without the email reminders. You can easily deploy using Docker with one-click:&lt;/p&gt;

&lt;p&gt;Github: &lt;a href="https://github.com/hamzasaleem2/subra-local" rel="noopener noreferrer"&gt;https://github.com/hamzasaleem2/subra-local&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>opensource</category>
      <category>software</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Subscription Hell? I Built a Way Out</title>
      <dc:creator>Hamza Saleem</dc:creator>
      <pubDate>Sun, 02 Mar 2025 17:15:03 +0000</pubDate>
      <link>https://forem.com/hamzasaleem/subscription-hell-i-built-a-way-out-1okj</link>
      <guid>https://forem.com/hamzasaleem/subscription-hell-i-built-a-way-out-1okj</guid>
      <description>&lt;p&gt;I built Subra out of frustration with my own subscription mess.... forgotten trials, surprise charges, and that constant "wait, how much am I spending?" feeling.&lt;/p&gt;

&lt;p&gt;It's straightforward: Subra tracks your subscriptions in one clean dashboard, calculates your total spend, and reminds you before payments hit your card. No fluff, no complex features you don't need.&lt;/p&gt;

&lt;p&gt;The core tools are free to use:&lt;br&gt;
Subscription calculator&lt;br&gt;
Family plan cost splitter&lt;br&gt;
Spending analyzer&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://subra.app/" rel="noopener noreferrer"&gt;https://subra.app&lt;/a&gt;. No signup needed for the basic tools.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>news</category>
    </item>
    <item>
      <title>Keeping Users in Sync: Building Real-time Collaboration with Convex</title>
      <dc:creator>Hamza Saleem</dc:creator>
      <pubDate>Sun, 09 Feb 2025 17:24:33 +0000</pubDate>
      <link>https://forem.com/convexchampions/keeping-users-in-sync-building-real-time-collaboration-with-convex-1ldh</link>
      <guid>https://forem.com/convexchampions/keeping-users-in-sync-building-real-time-collaboration-with-convex-1ldh</guid>
      <description>&lt;p&gt;&lt;a href="https://sticky.today" rel="noopener noreferrer"&gt;Sticky&lt;/a&gt; my startup, is inspired by the idea of making collaboration as natural as doing it in person. Building features for Stick was both challenging and exciting experience. As I worked through it, I gained alot of insights on how to keep users in sync, handle data efficiently and to make sure everything works smoothly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F63dc6ugj32jegurbbd8u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F63dc6ugj32jegurbbd8u.png" alt="Board" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Real Time Collaboration?
&lt;/h2&gt;

&lt;p&gt;Realtime collaboration is important for modern workflow. Either for brainstorming some ideas, editing documents or debugging your code. Immediate feedback keeps the momentum alive. It lets you bounce ideas of off each other just like you will do in person.&lt;/p&gt;

&lt;p&gt;Apps like Google Docs or Figma have done a great job making this possible. I tried to bring the same effortless feel to brainstorming ideas using sticky notes,&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges making Real Time Collaboration work:
&lt;/h2&gt;

&lt;p&gt;Its not just about syncing data but also about creating a seamless and engaging experience. Here's how i approached some of the problems using Convex:&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync and Optimistic Updates
&lt;/h2&gt;

&lt;p&gt;Syncing data in real time is a common challenge in a collaborative app. In Sticky, each user's change needs to be reflected across all the active sessions instantly the traditional approach would require creating a complex system using Pollling and WebSockets. Which requires ton of custom logic and backend setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Convex Solves This:
&lt;/h2&gt;

&lt;p&gt;Convex simplifies this with a native real-time data sync mechanism. By using the useQuery hook, you can easily fetch and sync data, like sticky notes, with minimal setup. Convex handles the underlying complexities of WebSocket connections, keeping all clients in sync automatically. This lets developers focus on creating features instead of managing the socket connections to keep everything connected in real time&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimistic Updates:
&lt;/h2&gt;

&lt;p&gt;When collaborating in real time, you'd expect changes to appear instantly, however network latency can cause delays in reflecting these updates making the user experience sluggish. to address this properly. I used optimistic updates to instantly reflect changes in the UI even before server save changes in the backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Optimistic Updates Help Solve:
&lt;/h2&gt;

&lt;p&gt;Optimistic updates solve the problem of UI delay caused by network latency. When user creates a new note the change is immediately shown in the Board even though the mutation maybe still be pending. The change is rolled back if mutation fails ensuring data consistency without effecting the user experience.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const createNote = useMutation(api.notes.createNote).withOptimisticUpdate(
  (localStore, args) =&amp;gt; {
    const existingNotes =
      localStore.getQuery(api.notes.getNotes, { boardId: actualBoardId }) || [];
    const tempId = `temp_${Date.now()}` as Id&amp;lt;"notes"&amp;gt;;
    const now = Date.now();
    localStore.setQuery(api.notes.getNotes, { boardId: actualBoardId }, [
      ...existingNotes,
      { _id: tempId, _creationTime: now, ...args },
    ]);
  }
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By immediately showing the new note on the board, users can continue collaborating without waiting for the server's response. Only if the mutation completes with a different result will the UI be updated accordingly.&lt;/p&gt;

&lt;p&gt;Real-time Presence Tracking Tracking who is online and where they are on the board is essential for real-time collaboration. In Sticky, I needed to show users cursor positions, indicating where others are working on the board. Real-time presence tracking also ensures that the team knows who is active and engaged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned:
&lt;/h2&gt;

&lt;p&gt;Implementing real-time presence required handling frequent updates of user cursor positions, which can overwhelm the system if not managed efficiently.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Convex Solves This:
&lt;/h2&gt;

&lt;p&gt;I used debounced updates to efficiently manage the frequency of presence updates. This function ensures the database isn't overloaded with too many requests by limiting the frequent updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Here’s a quick explanation of debouncing:
&lt;/h2&gt;

&lt;p&gt;debouncing is a technique where the function only triggers after a specified delay, avoiding redundant or unnecessary calls. In this case, it helps prevent sending excessive updates for small, rapid movements of the cursor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export function usePresence(boardId: Id&amp;lt;"boards"&amp;gt;, isShared: boolean) {
  const updatePresence = useMutation(api.presence.updatePresence);
  const removePresence = useMutation(api.presence.removePresence);
  const activeUsers = useQuery(api.presence.getActiveUsers, { boardId });
  const cursorPositionRef = useRef({ x: 0, y: 0 });
  const [localCursorPosition, setLocalCursorPosition] = useState({ x: 0, y: 0 });

  const debouncedUpdatePresence = useCallback(
    debounce((position: { x: number; y: number }) =&amp;gt; {
      if (isShared) {
        updatePresence({
          boardId,
          cursorPosition: position,
          isHeartbeat: false
        });
      }
    }, PRESENCE_UPDATE_INTERVAL, { maxWait: PRESENCE_UPDATE_INTERVAL * 2 }),
    [boardId, updatePresence, isShared]
  );

  useEffect(() =&amp;gt; {
    if (!isShared) return;

    const heartbeatInterval = setInterval(() =&amp;gt; {
      updatePresence({
        boardId,
        cursorPosition: cursorPositionRef.current,
        isHeartbeat: true
      });
    }, HEARTBEAT_INTERVAL);

    return () =&amp;gt; {
      clearInterval(heartbeatInterval);
      removePresence({ boardId });
    };
  }, [boardId, updatePresence, removePresence, isShared]);

  return {
    activeUsers: isShared ? activeUsers : [],
    updateCursorPosition,
    localCursorPosition
  };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By debouncing the updates, the system can efficiently tracks user activity without overwhelming the backend or causing performance issues.&lt;/p&gt;

&lt;p&gt;Schema Design and Indexing As real-time data grows, efficient database design becomes crucial. I had to ensure that data could be retrieved quickly as the board and user base expanded.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Convex Helps:
&lt;/h2&gt;

&lt;p&gt;Convex's schema design allows for easy definition of tables and indexes.&lt;/p&gt;

&lt;p&gt;For example, the presence table schema enables efficient queries based on user and board, as well as tracking the last update time for each user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;presence: defineTable({
  userId: v.id("users"),
  boardId: v.id("boards"),
  lastUpdated: v.number(),
  cursorPosition: v.object({
    x: v.number(),
    y: v.number(),
  }),
})
  .index("by_board", ["boardId"])
  .index("by_user_and_board", ["userId", "boardId"])
  .index("by_board_and_lastUpdated", ["boardId", "lastUpdated"]);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These indexes ensure that as the database grows, data retrieval remains fast, even with a large number of active users or boards.&lt;/p&gt;

&lt;p&gt;End-to-End Type Safety A major advantage of using Convex is its seamless integration with TypeScript. The type safety that TypeScript offers across the stack is a huge benefit when building real-time systems. With type-safe queries, mutations, and schema definitions, I was able to catch potential bugs during development, making the entire process smoother and more predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson Learned
&lt;/h2&gt;

&lt;p&gt;I learned was how important is is for state management to be simple. Real time systems are naturally complex but with right tools it can be make a huge difference. With Convex, I can focus on creating a great user experience instead of worrying about the technicals.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Final Thought
&lt;/h2&gt;

&lt;p&gt;Building Sticky was a rewarding experience. The process taught me a lot about real-time systems and the value of tools that simplify complex tasks. For anyone looking to implement similar features, my advice is to start small, leverage existing tools, and iterate as you learn.&lt;/p&gt;

&lt;p&gt;Check out &lt;a href="https://sticky.today" rel="noopener noreferrer"&gt;Sticky&lt;/a&gt; to see these features in action or explore the source code for more implementation details.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>convex</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
