<?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>DEV Community: Hardik Gupta</title>
    <description>The latest articles on DEV Community by Hardik Gupta (@strykerinside).</description>
    <link>https://dev.to/strykerinside</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1970389%2F0b6d0feb-50f8-4f39-8efa-c07262158454.png</url>
      <title>DEV Community: Hardik Gupta</title>
      <link>https://dev.to/strykerinside</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/strykerinside"/>
    <language>en</language>
    <item>
      <title>Inside the Engineering of Linkit</title>
      <dc:creator>Hardik Gupta</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:56:09 +0000</pubDate>
      <link>https://dev.to/strykerinside/inside-the-engineering-of-linkit-3g2n</link>
      <guid>https://dev.to/strykerinside/inside-the-engineering-of-linkit-3g2n</guid>
      <description>&lt;p&gt;I built &lt;strong&gt;Linkit&lt;/strong&gt; - a link-in-bio SaaS for creators, freelancers, and small businesses. It started as a simple link manager and grew into profiles, forms, a lightweight shop, social caching, audience messaging, and an AI assistant (Lynki).&lt;/p&gt;

&lt;p&gt;This post is about the &lt;strong&gt;main React app&lt;/strong&gt; and &lt;strong&gt;Express API&lt;/strong&gt; only. I'll walk through how the system works, the mistakes that hurt me in production, and what I'd change if I started again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Stack in one line:&lt;/strong&gt; Vite + React + Tailwind/shadcn on the frontend, Express on Node for secrets/AI/OAuth, Firebase Auth + Firestore + Storage as the data plane, deployed on Vercel.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;Most link-in-bio tools are either too shallow (a list of buttons) or too expensive. I wanted one product where a creator could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;share a public page (&lt;code&gt;/:username&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;manage links and design from a dashboard&lt;/li&gt;
&lt;li&gt;collect leads with forms&lt;/li&gt;
&lt;li&gt;take simple catalog orders&lt;/li&gt;
&lt;li&gt;talk to visitors (and get AI help)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose &lt;strong&gt;Firebase&lt;/strong&gt; so I could ship Auth + DB + Storage fast. I added an &lt;strong&gt;Express backend&lt;/strong&gt; the moment I needed API keys, OAuth exchanges, scrapers, or rate-limited public AI - those never belong in &lt;code&gt;VITE_*&lt;/code&gt; env vars.&lt;/p&gt;




&lt;h2&gt;
  
  
  Overall architecture
&lt;/h2&gt;

&lt;p&gt;Linkit is a &lt;strong&gt;dual-path&lt;/strong&gt; system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Browser → Firebase&lt;/strong&gt; for user-owned CRUD (profiles, links, forms, orders), enforced by security rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser → Express&lt;/strong&gt; for secrets, AI, OAuth, crawling, and anything I can't safely do on the client
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     axios + ID token      ┌─────────────────┐
│  React SPA  │ ─────────────────────────►│  Express API    │
│  (Vite)     │                           │  api.linkitapp  │
└──────┬──────┘                           └────────┬────────┘
       │                                           │
       │ Firebase client SDK                       │ Firebase Admin
       ▼                                           ▼
┌──────────────────────────────────────────────────────────┐
│           Firebase Auth · Firestore · Storage            │
└──────────────────────────────────────────────────────────┘
       ▲
       │ PostHog (analytics)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://linkitapp.in&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://api.linkitapp.in&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;:8080&lt;/code&gt; (app) + &lt;code&gt;:5001&lt;/code&gt; (API)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Frontend
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;React 18 + TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Tailwind + shadcn/Radix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;axios (&lt;code&gt;src/lib/api.ts&lt;/code&gt;) - attaches Firebase ID token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icons&lt;/td&gt;
&lt;td&gt;Phosphor (+ SVGL for social brands)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;React Context - no Redux/Zustand&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Provider order in &lt;code&gt;App.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QueryClient → Auth → Profile → Currency → UI → Cart → Router → App
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important routing rule:&lt;/strong&gt; the public profile catch-all &lt;code&gt;/:username&lt;/code&gt; must stay near the end of the route table. Put a new route after it and you break every public profile.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I read Firestore (and why I don't use onSnapshot everywhere)
&lt;/h3&gt;

&lt;p&gt;Early on I used live &lt;code&gt;onSnapshot&lt;/code&gt; listeners like everyone does. On Firebase Spark, that burned quota and multiplied listener churn.&lt;/p&gt;

&lt;p&gt;So I built a shared poll helper (&lt;code&gt;sharedFirestorePoll.ts&lt;/code&gt;): one interval, many React subscribers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Interval&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Links&lt;/td&gt;
&lt;td&gt;~90s&lt;/td&gt;
&lt;td&gt;Dashboard lists don't need live updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forms&lt;/td&gt;
&lt;td&gt;~120s&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile doc&lt;/td&gt;
&lt;td&gt;~300s&lt;/td&gt;
&lt;td&gt;Changes rarely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audience messages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;live snapshot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chat felt broken under polling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real-time is a product choice, not a default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backend
&lt;/h2&gt;

&lt;p&gt;Express ESM app. Thin routes, logic in &lt;code&gt;services/&lt;/code&gt;, auth in middleware.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/auth          token helpers, student domains
/forms         share, submit, integrations
/ai            Lynki + public chat + helpers
/social        OAuth, refresh, metrics
/cloudinary    authenticated upload/delete
/payment       → HTTP 410 (removed)
/order-payment → HTTP 410 (removed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auth middleware is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoded&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;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;verifyIdToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;optionalVerifyToken&lt;/code&gt; for guest-friendly routes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;requireAdmin&lt;/code&gt; (custom claim &lt;strong&gt;or&lt;/strong&gt; allowlisted UID)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;budgetGuard&lt;/code&gt; IP rate limits for public/AI endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Social refresh can run &lt;strong&gt;inline&lt;/strong&gt; in the API process (serverless-friendly) or as a &lt;strong&gt;BullMQ worker&lt;/strong&gt; when Redis is available.&lt;/p&gt;




&lt;h2&gt;
  
  
  Database
&lt;/h2&gt;

&lt;p&gt;Firestore is the primary DB. Rules are &lt;strong&gt;default deny&lt;/strong&gt;, then explicit allows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;users ──► profiles ──► links
                │
                ├── forms ──► form_submissions
                ├── catalogs ──► products
                ├── orders
                ├── audience_threads ──► messages
                └── social_* / public_profile_cache

usernames          (unique handle reservation)
user_integrations  (OAuth tokens - server-oriented)
analytics          (owner-only writes - see quota story)
lynki_*            (usage + BYOK settings)
app_config         (maintenance mode, testers)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why Firestore:&lt;/strong&gt; fast to ship with Auth/Storage.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; Spark quotas are unforgiving, and it's a bad public event bus (I learned that the hard way).&lt;/p&gt;

&lt;p&gt;I almost used Supabase early - the dependency is still in &lt;code&gt;package.json&lt;/code&gt; as a fossil. It was never wired.&lt;/p&gt;


&lt;h2&gt;
  
  
  Authentication flow
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User signs in (email / Google / GitHub)
        │
        ▼
Firebase Auth session
        │
        ├── ProtectedRoute → dashboard
        └── api.ts interceptor → Authorization: Bearer &amp;lt;idToken&amp;gt;
                                        │
                                        ▼
                              Express verifyIdToken
                                        │
                                        ▼
                                   req.uid set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I harden redirects with &lt;code&gt;buildAuthHref()&lt;/code&gt; so a &lt;code&gt;?redirect=&lt;/code&gt; query can't become an open redirect.&lt;/p&gt;


&lt;h2&gt;
  
  
  Files and media
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Firebase Storage&lt;/strong&gt; for user/profile/form assets, path-scoped in &lt;code&gt;storage.rules&lt;/code&gt; (~15MB caps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudinary&lt;/strong&gt; for backgrounds/transforms; backend upload proxy keeps secrets server-side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Early storage rules were too broad (any authenticated user could touch too much). I scoped them to &lt;code&gt;users/&lt;/code&gt;, &lt;code&gt;profiles/&lt;/code&gt;, and &lt;code&gt;forms/&lt;/code&gt; after a production audit.&lt;/p&gt;


&lt;h2&gt;
  
  
  AI (Lynki)
&lt;/h2&gt;

&lt;p&gt;Two modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Visitor chat on public profiles&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /ai/public-chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional auth + IP rate limit + feature flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /ai/lynki/chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required auth + usage caps or BYOK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A provider router falls back across OpenRouter / Groq / GitHub Models. The model can propose actions; the &lt;strong&gt;client&lt;/strong&gt; executes drafts. I don't give the model raw Firestore write access.&lt;/p&gt;


&lt;h2&gt;
  
  
  Payments (or: how I deleted them)
&lt;/h2&gt;

&lt;p&gt;All features are free today. Prices in &lt;code&gt;shared/linkitPricingDefaults.js&lt;/code&gt; are &lt;code&gt;0&lt;/code&gt;. Payment routes return &lt;strong&gt;410 Gone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I still keep plan enums (&lt;code&gt;free&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;badge&lt;/code&gt;, &lt;code&gt;student&lt;/code&gt;) and an entitlements module - but they currently unlock everything. That makes re-monetization possible without rewriting the UI.&lt;/p&gt;

&lt;p&gt;I had previously built Cashfree flows, shop gates, and payment settings. Then the product decision flipped. Monetization reversed faster than the architecture - so I tombstoned APIs and cleaned dead CTAs/tours in an audit pass instead of leaving ghosts.&lt;/p&gt;


&lt;h2&gt;
  
  
  Deploy and ops
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Git push
   ├── Vercel frontend (SPA → index.html)
   ├── Vercel backend (serverless Express)
   └── Firebase (rules + indexes + storage rules)

Optional: Redis + social worker on a small VPS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Secrets live only in &lt;code&gt;backend/.env&lt;/code&gt;. Anything &lt;code&gt;VITE_*&lt;/code&gt; is public in the browser bundle.&lt;/p&gt;

&lt;p&gt;I smoke-test with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run smoke:stabilization
npm run lint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Caching (really: quota defense)
&lt;/h2&gt;

&lt;p&gt;I don't have a fancy multi-layer CDN cache story. Most "caching" is about not dying on Spark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What I do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard reads&lt;/td&gt;
&lt;td&gt;Shared polls (90–300s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quota spike&lt;/td&gt;
&lt;td&gt;Pause polls until next UTC day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social&lt;/td&gt;
&lt;td&gt;Materialized &lt;code&gt;public_profile_cache&lt;/code&gt; + queued refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Prefer PostHog; Firestore increments behind a kill switch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Security (the non-negotiables)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Default-deny Firestore/Storage rules&lt;/li&gt;
&lt;li&gt;Firebase ID tokens on protected APIs&lt;/li&gt;
&lt;li&gt;CORS allowlist + Helmet&lt;/li&gt;
&lt;li&gt;Sanitize user HTML (DOMPurify)&lt;/li&gt;
&lt;li&gt;Rate-limit public AI/forms/uploads&lt;/li&gt;
&lt;li&gt;Safe auth redirects&lt;/li&gt;
&lt;li&gt;No secrets in &lt;code&gt;VITE_*&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still imperfect: public profile docs can expose more fields than a minimal DTO. I'd split owner-private fields into a subcollection if I rebuilt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Request lifecycle (happy path)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dashboard API call&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UI → api.get('/…') → attach ID token → Express → verifyToken → service → JSON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Public profile&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/:username → query profile by username → load links/products
          → PostHog view event
          → optional /ai/public-chat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Social refresh&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trigger → enqueue job → crawler → write cache/crawl state
        (never scrape live on the hot request path)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Folder structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/                 # React app
  pages/             # routes
  components/        # features + ui/
  contexts/          # Auth, Profile, Currency, UI, Cart
  hooks/             # polling hooks
  lib/               # api, poll, authFlow, lynki, errors
backend/
  routes/            # HTTP
  services/          # business logic
  middleware/        # auth, budgetGuard
  workers/           # social refresh
shared/              # pricing defaults (FE + BE)
firestore.rules
storage.rules
AGENTS.md            # living engineering handbook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The engineering journey (what actually happened)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Ship fast on Firebase
&lt;/h3&gt;

&lt;p&gt;Vite + React + shadcn + Firebase. Link Manager first. Express when secrets appeared. That bet was right for speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Features exploded
&lt;/h3&gt;

&lt;p&gt;Forms, shop, social, Lynki, admin, maintenance mode. The app stopped being "a link list."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. I turned payments on… then off
&lt;/h3&gt;

&lt;p&gt;I integrated Cashfree, gated shops behind payment settings, and used live listeners on orders. Later I made everything free. The painful part wasn't deleting payment code - it was finding every tour, CTA, and gate that still pointed at a dead flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if monetization can flip, put it behind flags and ship &lt;strong&gt;410 tombstones&lt;/strong&gt; when you remove it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The Firestore quota crisis
&lt;/h3&gt;

&lt;p&gt;Public profile pages called analytics writes from the browser. Rules allowed essentially anyone to update &lt;code&gt;analytics/{userId}&lt;/code&gt;. Profiles are crawlable. Bots don't care about your DAU.&lt;/p&gt;

&lt;p&gt;I saw a write spike on the order of &lt;strong&gt;~20k&lt;/strong&gt; that looked like traffic, but was mostly crawlers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixes that became architecture:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Analytics writes → &lt;strong&gt;owner-only&lt;/strong&gt; in rules
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VITE_ENABLE_FIRESTORE_ANALYTICS=false&lt;/code&gt; by default
&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;PostHog&lt;/strong&gt; for public metrics
&lt;/li&gt;
&lt;li&gt;Replace most &lt;code&gt;onSnapshot&lt;/code&gt; with &lt;strong&gt;shared polling&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Quota circuit breaker in the poll layer
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; never use your primary user database as a public counter store.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Polling broke chat - so I brought snapshots back (narrowly)
&lt;/h3&gt;

&lt;p&gt;After the quota scare I tried to keep audience messaging on polls. Send/receive felt unreliable. Chat is latency-sensitive.&lt;/p&gt;

&lt;p&gt;I fixed it with batch writes + &lt;strong&gt;Messages-tab-scoped&lt;/strong&gt; live listeners. Real-time returned - but only where the product needs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Social scraping on the request path failed
&lt;/h3&gt;

&lt;p&gt;Live browser scrapes melted small hosts. I moved to cache-first orchestration, queued refresh, differential crawl, and default-off expensive flags.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Production audit (July 2026)
&lt;/h3&gt;

&lt;p&gt;I cleaned payment ghosts, scoped storage rules, centralized error mapping, and fixed audience reliability. Bundle size and unfinished design-token migration are still debt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistakes I'd warn my past self about
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public client writes to Firestore&lt;/strong&gt; - bots will find them
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;onSnapshot&lt;/code&gt; as default&lt;/strong&gt; - pay for real-time only when UX requires it
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing payments without an audit checklist&lt;/strong&gt; - dead CTAs linger
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broad storage rules&lt;/strong&gt; - scope by owner path
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving unused deps&lt;/strong&gt; (Supabase) - they lie to every future reader
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half-finished design migrations&lt;/strong&gt; - finish or delete
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Putting routes after &lt;code&gt;/:username&lt;/code&gt;&lt;/strong&gt; - instant public-profile breakage
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What I'd build differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep &lt;strong&gt;product analytics outside Firestore&lt;/strong&gt; from day one
&lt;/li&gt;
&lt;li&gt;Ship a &lt;strong&gt;public profile DTO&lt;/strong&gt; - don't serve the whole owner document
&lt;/li&gt;
&lt;li&gt;Make a &lt;strong&gt;worker service&lt;/strong&gt; first-class (don't pretend serverless HTTP is a crawler host)
&lt;/li&gt;
&lt;li&gt;Put &lt;strong&gt;payments behind a feature flag&lt;/strong&gt; before writing UI gates
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code-split&lt;/strong&gt; heavy pages (Profile, Forms, Link Manager) immediately
&lt;/li&gt;
&lt;li&gt;Finish &lt;strong&gt;one&lt;/strong&gt; design system before feature explosion
&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Redis/edge rate limits&lt;/strong&gt; before going multi-instance
&lt;/li&gt;
&lt;li&gt;Write the incident notes while they're hot - and don't delete them&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Linkit taught me that architecture isn't just boxes on a diagram. On a Firebase Spark + Vercel budget, &lt;strong&gt;rules, quotas, and abuse paths&lt;/strong&gt; shape the product as much as React components do.&lt;/p&gt;

&lt;p&gt;If you're building a similar dual-path Firebase + Express app: steal the dual-path clarity, the poll-by-default habit, and the 410 tombstone idea. Try not to steal the analytics-write mistake.&lt;/p&gt;




&lt;h3&gt;
  
  
  Quick reference
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React + Vite (&lt;code&gt;src/&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Express (&lt;code&gt;backend/&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Firebase ID tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;Firestore (default deny)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files&lt;/td&gt;
&lt;td&gt;Storage + Cloudinary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Removed (410); everything free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;PostHog preferred&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sharedFirestorePoll.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handbook&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>architecture</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>How SigNoz Helped Me Debug Linkit Before 200+ Creators Noticed the Problem</title>
      <dc:creator>Hardik Gupta</dc:creator>
      <pubDate>Thu, 16 Jul 2026 08:00:29 +0000</pubDate>
      <link>https://dev.to/strykerinside/how-signoz-helped-me-debug-linkit-before-200-creators-noticed-the-problem-bdl</link>
      <guid>https://dev.to/strykerinside/how-signoz-helped-me-debug-linkit-before-200-creators-noticed-the-problem-bdl</guid>
      <description>&lt;p&gt;When you're building a product that's actually being used, debugging changes completely.&lt;/p&gt;

&lt;p&gt;Console logs work when you're the only user.&lt;/p&gt;

&lt;p&gt;They don't work when hundreds of people are creating pages, scanning QR codes, uploading products, and opening links from different devices at the same time.&lt;/p&gt;

&lt;p&gt;I ran into exactly that while building &lt;strong&gt;Linkit&lt;/strong&gt;, a creator platform that lets creators, cafés, and businesses build customizable link-in-bio pages with product catalogs, digital menus, forms, and AI-powered tools.&lt;/p&gt;

&lt;p&gt;Today, Linkit powers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;500+ creators and businesses&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;35,000+ public profile visits&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;120,000+ link clicks&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;18,000+ QR code scans&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4,000+ form submissions&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Users from &lt;strong&gt;15+ countries&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As usage grew, I started noticing an annoying issue.&lt;/p&gt;

&lt;p&gt;Some users said their pages opened instantly.&lt;/p&gt;

&lt;p&gt;Others reported that the exact same page sometimes took 4–5 seconds to load.&lt;/p&gt;

&lt;p&gt;The worst part?&lt;/p&gt;

&lt;p&gt;I couldn't reproduce it consistently.&lt;/p&gt;

&lt;p&gt;Everything looked normal on my machine.&lt;/p&gt;

&lt;p&gt;This is where I stopped relying on logs and started using &lt;strong&gt;OpenTelemetry&lt;/strong&gt; with &lt;strong&gt;SigNoz&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  Architecture
&lt;/h1&gt;

&lt;p&gt;Linkit isn't just a React frontend.&lt;/p&gt;

&lt;p&gt;Every request goes through multiple services before the final page is rendered.&lt;/p&gt;

&lt;p&gt;Current stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React + Vite&lt;/li&gt;
&lt;li&gt;Node.js + Express&lt;/li&gt;
&lt;li&gt;PostgreSQL&lt;/li&gt;
&lt;li&gt;Redis&lt;/li&gt;
&lt;li&gt;Cloudinary&lt;/li&gt;
&lt;li&gt;OpenTelemetry&lt;/li&gt;
&lt;li&gt;SigNoz&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single request for a public profile may involve:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Fetching profile data&lt;/li&gt;
&lt;li&gt;Loading products&lt;/li&gt;
&lt;li&gt;Fetching analytics&lt;/li&gt;
&lt;li&gt;Loading custom theme&lt;/li&gt;
&lt;li&gt;Resolving images&lt;/li&gt;
&lt;li&gt;Returning the final response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without distributed tracing, figuring out which step is slow is mostly guessing.&lt;/p&gt;




&lt;h1&gt;
  
  
  Adding OpenTelemetry
&lt;/h1&gt;

&lt;p&gt;Getting started was surprisingly simple.&lt;/p&gt;

&lt;p&gt;Install the packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @opentelemetry/sdk-node &lt;span class="se"&gt;\&lt;/span&gt;
@opentelemetry/auto-instrumentations-node &lt;span class="se"&gt;\&lt;/span&gt;
@opentelemetry/exporter-trace-otlp-http
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create an instrumentation file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;NodeSDK&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@opentelemetry/sdk-node&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getNodeAutoInstrumentations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@opentelemetry/auto-instrumentations-node&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;OTLPTraceExporter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@opentelemetry/exporter-trace-otlp-http&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;sdk&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;NodeSDK&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;traceExporter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OTLPTraceExporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:4318/v1/traces&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;instrumentations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;getNodeAutoInstrumentations&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--require&lt;/span&gt; ./instrumentation.js server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within minutes, traces started appearing inside SigNoz.&lt;/p&gt;




&lt;h1&gt;
  
  
  The First Discovery
&lt;/h1&gt;

&lt;p&gt;Initially, I assumed PostgreSQL was causing the slowdown.&lt;/p&gt;

&lt;p&gt;The traces immediately proved me wrong.&lt;/p&gt;

&lt;p&gt;Typical request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Incoming Request
        │
        ▼
Authentication (12 ms)
        │
        ▼
Database Query (24 ms)
        │
        ▼
Generate Components (430 ms)
        │
        ▼
Cloudinary Lookup (280 ms)
        │
        ▼
Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database wasn't the problem.&lt;/p&gt;

&lt;p&gt;It only accounted for around &lt;strong&gt;24 milliseconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Most of the request time was being spent generating page components and fetching media.&lt;/p&gt;

&lt;p&gt;Without tracing, I would've spent hours optimizing SQL queries that were already fast.&lt;/p&gt;




&lt;h1&gt;
  
  
  Finding Duplicate Work
&lt;/h1&gt;

&lt;p&gt;Every Linkit page can contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Social links&lt;/li&gt;
&lt;li&gt;Products&lt;/li&gt;
&lt;li&gt;Forms&lt;/li&gt;
&lt;li&gt;Digital menus&lt;/li&gt;
&lt;li&gt;Embedded videos&lt;/li&gt;
&lt;li&gt;Analytics widgets&lt;/li&gt;
&lt;li&gt;AI-generated sections&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SigNoz traces showed that some profile layouts were requesting identical resources multiple times.&lt;/p&gt;

&lt;p&gt;For one page, the same API endpoint was called &lt;strong&gt;four times&lt;/strong&gt; during a single request.&lt;/p&gt;

&lt;p&gt;That wasn't obvious from logs.&lt;/p&gt;

&lt;p&gt;Adding a small Redis cache reduced page generation time from:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;920 ms → 310 ms&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No frontend changes.&lt;/p&gt;

&lt;p&gt;No database optimization.&lt;/p&gt;

&lt;p&gt;Just removing unnecessary work.&lt;/p&gt;




&lt;h1&gt;
  
  
  Monitoring Traffic
&lt;/h1&gt;

&lt;p&gt;As Linkit grew, I stopped opening terminal logs altogether.&lt;/p&gt;

&lt;p&gt;Instead, I kept a SigNoz dashboard open.&lt;/p&gt;

&lt;p&gt;It answered questions instantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which endpoint is slow today?&lt;/li&gt;
&lt;li&gt;Are uploads failing?&lt;/li&gt;
&lt;li&gt;How many requests are arriving every minute?&lt;/li&gt;
&lt;li&gt;Is Redis healthy?&lt;/li&gt;
&lt;li&gt;Did the latest deployment increase latency?&lt;/li&gt;
&lt;li&gt;Is memory usage stable?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Having all of that in one place made debugging dramatically faster.&lt;/p&gt;




&lt;h1&gt;
  
  
  One Bug That Would've Taken Hours
&lt;/h1&gt;

&lt;p&gt;A creator reported that image uploads occasionally failed.&lt;/p&gt;

&lt;p&gt;The frontend simply displayed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload Failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not exactly useful.&lt;/p&gt;

&lt;p&gt;The trace showed something completely different.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend

↓

Authentication

↓

Generate Upload Signature

↓

Cloudinary API

↓

Timeout

↓

Retry

↓

Success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend wasn't failing.&lt;/p&gt;

&lt;p&gt;Cloudinary occasionally timed out, triggering the retry mechanism.&lt;/p&gt;

&lt;p&gt;Instead of rewriting upload code, I adjusted timeout settings and improved retry logic.&lt;/p&gt;

&lt;p&gt;Problem solved.&lt;/p&gt;




&lt;h1&gt;
  
  
  Error Monitoring
&lt;/h1&gt;

&lt;p&gt;One endpoint consistently had a higher failure rate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/forms/submit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening the traces revealed the issue immediately.&lt;/p&gt;

&lt;p&gt;Some users were uploading files larger than the backend allowed.&lt;/p&gt;

&lt;p&gt;Instead of returning generic 500 errors, I added proper validation and descriptive error messages.&lt;/p&gt;

&lt;p&gt;Support requests dropped almost immediately.&lt;/p&gt;




&lt;h1&gt;
  
  
  Business Metrics Matter Too
&lt;/h1&gt;

&lt;p&gt;Technical metrics are useful.&lt;/p&gt;

&lt;p&gt;Business metrics are even better.&lt;/p&gt;

&lt;p&gt;Besides the default dashboards, I created custom metrics for Linkit.&lt;/p&gt;

&lt;p&gt;These included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New creator registrations&lt;/li&gt;
&lt;li&gt;Published pages&lt;/li&gt;
&lt;li&gt;QR scans&lt;/li&gt;
&lt;li&gt;Link clicks&lt;/li&gt;
&lt;li&gt;Product purchases&lt;/li&gt;
&lt;li&gt;Form submissions&lt;/li&gt;
&lt;li&gt;AI content generations&lt;/li&gt;
&lt;li&gt;Payment events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Being able to correlate product activity with infrastructure metrics helped answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did increased traffic actually slow the application?&lt;/li&gt;
&lt;li&gt;Did yesterday's deployment reduce conversion?&lt;/li&gt;
&lt;li&gt;Which features receive the most usage?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of only monitoring servers, I was monitoring the product itself.&lt;/p&gt;




&lt;h1&gt;
  
  
  Deployments Became Less Stressful
&lt;/h1&gt;

&lt;p&gt;Before using SigNoz, deploying meant:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Push code.&lt;/li&gt;
&lt;li&gt;Refresh the app.&lt;/li&gt;
&lt;li&gt;Hope everything works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now every deployment is followed by watching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Error rate&lt;/li&gt;
&lt;li&gt;Request latency&lt;/li&gt;
&lt;li&gt;Memory usage&lt;/li&gt;
&lt;li&gt;CPU utilization&lt;/li&gt;
&lt;li&gt;Slow traces&lt;/li&gt;
&lt;li&gt;Database performance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If anything spikes, it's visible within minutes.&lt;/p&gt;

&lt;p&gt;That confidence alone makes observability worth adding.&lt;/p&gt;




&lt;h1&gt;
  
  
  Lessons Learned
&lt;/h1&gt;

&lt;p&gt;The biggest lesson wasn't finding bugs.&lt;/p&gt;

&lt;p&gt;It was learning where the bugs &lt;strong&gt;weren't&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Several times I blamed PostgreSQL.&lt;/p&gt;

&lt;p&gt;The traces showed perfectly healthy queries.&lt;/p&gt;

&lt;p&gt;Other times I suspected Redis.&lt;/p&gt;

&lt;p&gt;The real issue was duplicate API requests.&lt;/p&gt;

&lt;p&gt;Without observability, I would've optimized the wrong parts of the application.&lt;/p&gt;

&lt;p&gt;With traces and metrics, every optimization was backed by evidence instead of assumptions.&lt;/p&gt;




&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;Adding OpenTelemetry and SigNoz changed how I debug Linkit.&lt;/p&gt;

&lt;p&gt;Instead of relying on console logs and intuition, I now have visibility into every request, every database query, every external API call, and every performance bottleneck.&lt;/p&gt;

&lt;p&gt;As Linkit continues to grow, observability has become just as important as testing.&lt;/p&gt;

&lt;p&gt;Building features gets users.&lt;/p&gt;

&lt;p&gt;Keeping those features fast, reliable, and measurable keeps them coming back.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>monitoring</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Bloom Filters for AI Agents: The Small Cache Trick That Saved My Retrieval Pipeline</title>
      <dc:creator>Hardik Gupta</dc:creator>
      <pubDate>Fri, 17 Apr 2026 18:07:16 +0000</pubDate>
      <link>https://dev.to/strykerinside/bloom-filters-for-ai-agents-the-small-cache-trick-that-saved-my-retrieval-pipeline-5cd4</link>
      <guid>https://dev.to/strykerinside/bloom-filters-for-ai-agents-the-small-cache-trick-that-saved-my-retrieval-pipeline-5cd4</guid>
      <description>&lt;p&gt;Bloom filters felt like a purely academic data structure - until an agent pipeline started repeating work. At that point, they became immediately practical.&lt;/p&gt;




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

&lt;p&gt;The system needed a fast, low-cost way to check whether something had &lt;em&gt;probably&lt;/em&gt; been seen before.&lt;/p&gt;

&lt;p&gt;Not certainty. A strong enough signal to avoid redundant work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Mode
&lt;/h2&gt;

&lt;p&gt;The agent repeatedly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;revisited identical document IDs
&lt;/li&gt;
&lt;li&gt;re-triggered the same tool calls
&lt;/li&gt;
&lt;li&gt;reprocessed items already handled minutes earlier
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This created:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unnecessary latency
&lt;/li&gt;
&lt;li&gt;increased compute cost
&lt;/li&gt;
&lt;li&gt;degraded pipeline efficiency
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lightweight pre-check layer was required.&lt;/p&gt;




&lt;h2&gt;
  
  
  Approach
&lt;/h2&gt;

&lt;p&gt;Introduce a Bloom filter as a front-line gate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;strong&gt;definitely new&lt;/strong&gt; → process
&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;possibly seen&lt;/strong&gt; → verify via authoritative store
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Properties leveraged:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No false negatives
&lt;/li&gt;
&lt;li&gt;Acceptable false positives
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Mental Model
&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6eg9ewz6rgb6i5nbh56y.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%2F6eg9ewz6rgb6i5nbh56y.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Bloom filter consists of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a fixed-size bit array
&lt;/li&gt;
&lt;li&gt;multiple hash functions
&lt;/li&gt;
&lt;li&gt;a probabilistic membership check
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Insert&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hash value multiple times
&lt;/li&gt;
&lt;li&gt;set corresponding bits to &lt;code&gt;1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Query&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if any bit is &lt;code&gt;0&lt;/code&gt; → &lt;strong&gt;definitely not present&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;if all bits are &lt;code&gt;1&lt;/code&gt; → &lt;strong&gt;possibly present&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BloomFilter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;bits&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;seeds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;73&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seed&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="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;seed&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seeds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seeds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&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;h2&gt;
  
  
  Where It Fit in My Agent Stack
&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fup9994eu4ioovnpetu03.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%2Fup9994eu4ioovnpetu03.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I ended up using Bloom filters in three key places:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Event Deduplication
&lt;/h3&gt;

&lt;p&gt;Before the agent processes anything, I filter out repeated inputs. This alone removed a lot of noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Retrieval Optimization
&lt;/h3&gt;

&lt;p&gt;While scanning candidate documents, I skip anything that has likely been seen before. This reduced unnecessary lookups.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tool Call Short-Circuiting
&lt;/h3&gt;

&lt;p&gt;This was the biggest win.&lt;/p&gt;

&lt;p&gt;Agents tend to repeat tool calls when context becomes messy. A Bloom filter doesn’t fix reasoning, but it stops the system from wasting cycles on the same targets again and again.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tradeoff I Respect
&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%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fywqjczdqhbgwjon46z15.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%2Fywqjczdqhbgwjon46z15.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I don’t use Bloom filters when I need certainty.&lt;/p&gt;

&lt;p&gt;I use them when I need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;speed
&lt;/li&gt;
&lt;li&gt;low memory usage
&lt;/li&gt;
&lt;li&gt;a fast first-pass filter
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are not a source of truth.&lt;/p&gt;

&lt;p&gt;They are a guardrail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Take
&lt;/h2&gt;

&lt;p&gt;Bloom filters work best as a front-line defense against wasted effort.&lt;/p&gt;

&lt;p&gt;They don’t fix reasoning.&lt;br&gt;&lt;br&gt;
They don’t improve intelligence.  &lt;/p&gt;

&lt;p&gt;What they do is enforce discipline in the system - quietly, efficiently, and at scale.&lt;/p&gt;

&lt;p&gt;In agent pipelines, that’s often exactly what is missing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;How do you handle deduplication in your AI workflows?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis / Postgres with exact checks?
&lt;/li&gt;
&lt;li&gt;Probabilistic structures like Bloom or Cuckoo filters?
&lt;/li&gt;
&lt;li&gt;Something hybrid?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bloomfilter</category>
      <category>agents</category>
      <category>typescript</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
