DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I Shipped a Realtime Collab App in 3 Lines of React — No WebSocket Plumbing

🧠 3-tier learning page: https://dev48v.infy.uk/tech/day44-convex.html

Day 44 of my TechFromZero series. Today: Convex — the reactive backend that compresses Postgres + Prisma + Express + Socket.io + Redis into a single product, and lets you ship realtime collab features in literally 3 lines of React.


Why it matters

Building a realtime collab feature today usually means:

  1. Set up Postgres (Supabase / Neon / Railway)
  2. Add Prisma + migrations
  3. Stand up an Express / Hono server
  4. Wire WebSocket auth + reconnect logic
  5. Build a subscribe/unsubscribe lifecycle
  6. Add optimistic UI

That's ~3 days of plumbing before the first feature ships. Convex collapses the entire stack:

┌────────────────────────────────────────────┐
│  useQuery + useMutation  (React)           │  3 lines of React
└──────────────────┬─────────────────────────┘
                   │ WebSocket (handled for you)
┌──────────────────▼─────────────────────────┐
│  query() / mutation() / action()  (TS)     │
│  + typed document DB                       │  ← One product
└────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The 3 React lines

"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";

const count = useQuery(api.counters.get, { name: "global" }) ?? 0;
const bump  = useMutation(api.counters.bump);

<button onClick={() => bump({ name: "global" })}>
  Count: {count}
</button>
Enter fullscreen mode Exit fullscreen mode

Open the page in 5 tabs. Click in one. All 5 update instantly. No subscribe code. No retry logic. No optimistic-UI plumbing. Convex's client handles every transport detail.


The backend (one schema + one query + one mutation)

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  counters: defineTable({
    name: v.string(),
    value: v.number(),
  }).index("by_name", ["name"]),
});
Enter fullscreen mode Exit fullscreen mode
// convex/counters.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const get = query({
  args: { name: v.string() },
  handler: async (ctx, { name }) => {
    const c = await ctx.db
      .query("counters")
      .withIndex("by_name", q => q.eq("name", name))
      .first();
    return c?.value ?? 0;
  }
});

export const bump = mutation({
  args: { name: v.string() },
  handler: async (ctx, { name }) => {
    const existing = await ctx.db.query("counters")
      .withIndex("by_name", q => q.eq("name", name))
      .first();
    if (existing) {
      await ctx.db.patch(existing._id, { value: existing.value + 1 });
    } else {
      await ctx.db.insert("counters", { name, value: 1 });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

That's the whole backend. Two TypeScript files, ~30 lines.


Three function types — query / mutation / action

Type Reads DB Writes DB Calls external APIs Auto-subscribed
query() ✅ (the magic)
mutation() ✅ (in tx)
action() ✅ via runQuery ✅ via runMutation ✅ (fetch, etc.)

query() is the heart of the magic — Convex tracks which rows your query function reads, and when any mutation later writes those rows, every subscribed client gets pushed the new result automatically.

action() is for external API calls (LLMs, Stripe, fetches) since queries + mutations are deterministic-replay. Actions call back into mutations to commit results.


What you skip

  • Postgres setup + Prisma migrations — 2 hours
  • Express + WebSocket auth — 1 day
  • subscribe/unsubscribe lifecycle — half day
  • Optimistic UI plumbing — 1 day

Total saved per realtime feature: ~4 days.


When NOT to use Convex

Skip Convex Use
100M+ row OLAP queries Postgres + Clickhouse
Video streaming / large blob storage S3 + Cloudflare R2
Strict SQL joins on 10+ tables Postgres + Prisma
Regulated data (HIPAA self-host) Self-hosted Postgres

For everything else — collab apps, AI chat, dashboards, MVPs, hackathons — Convex is the highest-leverage backend choice in 2025.


Bonus: vector search built in

You don't need Pinecone for small/medium RAG. Convex has native vector indexes:

defineTable({
  embedding: v.array(v.float64()),
  text: v.string(),
}).vectorIndex("by_embedding", {
  vectorField: "embedding",
  dimensions: 1536,
});

// Search:
const results = await ctx.vectorSearch(
  "messages",
  "by_embedding",
  { vector: queryEmbedding, limit: 5 }
);
Enter fullscreen mode Exit fullscreen mode

Free tier covers most prototypes.


Try it now

The 3-tier learning page has a live simulated collab counter + 9 click-through steps on each piece (schema, query, mutation, useQuery, useMutation, action, vector search, deploy):

https://dev48v.infy.uk/tech/day44-convex.html


Build it in 20 minutes

npx create-next-app@latest convex-from-zero --typescript --tailwind --app
cd convex-from-zero
npm install convex
npx convex dev   # opens browser, log in, free tier
Enter fullscreen mode Exit fullscreen mode

Convex dev starts a watcher that pushes your convex/*.ts files to your hosted dev deployment as you save. Free tier: 2 GB storage, 1M function calls / month.


What's next in TechFromZero

Day 45 picks tomorrow. Probably TanStack Start (new SSR framework) or View Transitions API (animated native page transitions).

🌐 All days: https://dev48v.infy.uk/techfromzero.php

Top comments (0)