In this case study

Architecture

Convex is the entire backend: database, functions, scheduler, crons, HTTP routes, the auth component, and the rate limiter, aggregates, email, push and workflow components that sit inside it. Everything else is a client or an external service.

Five clients, one backend

Slapp system: five clients, one Convex backend, external servicesFive clients, the Slapp app for iOS and Android, the web client, the admin dashboard, the Tilboð portal and the shop, all talk to one Convex backend, which in turn uses external services: Kenni, Cloudflare R2 and Stream, Google Cloud KMS, RevenueCat, Expo Push, Resend, OpenAI, Shopify and Printful.Slapp appiOS and AndroidWebwww.slapp.isAdminadmin.slapp.isTilboð portaltilbod.slapp.isShopmerch.slapp.isConvex backenddatabase, functions, crons, auth, 278 tablesExternal servicesKenni, Cloudflare R2 and Stream, Google Cloud KMS, RevenueCat, Expo Push,Resend, OpenAI, Shopify, Printful
Clients authenticate with a Better Auth session and a one-hour Convex JWT, and talk to one Convex deployment. The two football prediction APIs are the only services outside it.
ClientNotes
MobileExpo, 856 files, 257k lines, WebSocket plus polled queries
WebNext.js, 93 pages, server-rendered previews through the HTTP client
AdminNext.js, 42 pages, isAdmin gate
TilboðNext.js, 15 pages, merchant auth prefix
ShopNext.js, 1 page plus 4 routes

The mobile app is the main client; the product chapter describes it in depth. The web client mirrors the app's queries and adds no features of its own. Admin, the Tilboð portal and the shop are small Next.js apps that each keep their auth tokens under their own storage prefix, so a merchant session never collides with a consumer session in the same browser.

The backend in numbers

ComponentNotes
262 modules188 top-level plus 6 subdirectories, 154k lines
278 tables743 indexes, 12 search indexes, 4 vector indexes at 128 dimensions
88 crons41 daily, 39 interval, 7 weekly, 1 monthly
63 components55 aggregates, betterAuth, rateLimiter, r2, resend twice, push, workflow
69 rate limitstoken bucket and fixed window
8 Node-runtime filesemail, PDF, photo hashing, address sync
62 environment variablesread from the Convex dashboard, grouped by service

The schema file alone is 7,670 lines. Twelve search indexes cover users (three variants, including folded and trigram search), posts, comments, hashtags, groups, pages, group posts, music clips, the address registry and marketplace listings. The four vector indexes back user taste, post engagement, and two generations of graph embeddings, all at 128 dimensions.

Modules by domain

DomainModulesRepresentative files
Tilboð business offers21tilbod, tilbodAdmin, tilbodOffers, tilbodClaims, tilbodClubs, tilbodClubBilling, tilbodPeriod
Games and seasonal20stafli, geoGame, dailyChallenge, gameTrophies, djamm, thjodhatid, worldCup*, premierLeague*
Leigumarkaður rentals16leigaListings, leigaAI, leigaTrust, leigaPhotoHash, leigaTranslations, leigaAddressesSync
Notifications and growth ML14pushNotifications, hookNotifications, notificationRanker*, churnRisk*, postPropensity*
Markaðstorg marketplace14markadListings, markadTrades via markadInterests, markadReputation, markadRisk, markadAI
Feed and ranking12feed, feedRanked, feedRanker*, ranking, feedHealth, embeddings*, abTesting
Social graph12friends, userFriends, friendRanker*, graphSuggestions, node2vecTraining, tengsl, direction
Admin and analytics11admin, kpi, cohorts, featureImportance, adminARPU, adminMLAnalytics
Posts and content10posts, comments, likes, postCounters, drafts, dailyUpdates, linkPreviews
Infra and ops10schema, crons, http, migrations, appConfig, rateLimits, svaediAccess
Automated content9contentIngest, contentFrettir, contentVedrid, contentSkjalfta, contentFrodleikur, spurningDagsins
Monetisation8subscription, loyalty, slappPlusGroup, referrals, shop
Groups, pages, events7groups, groupPosts, groupDues, pages, events, eventPosts, schoolEmails
Auth and users6auth, users, onboarding, consent, utlis
Media and storage6r2Storage, storageHelpers, stream, videoLimits, instagramImport, musicClips
Moderation4blocking, moderation, reports, excludedUsers
Messaging3messaging, chatEvents, encryption
Dating, search, AI5dating, datingUpdateCampaign, search

Subdirectories hold 74 more files, among them 14 re-engagement signal evaluators, the Resend email pipeline with React Email templates, the local Better Auth component, pure helpers, and migrations.

Tables by domain

DomainTablesExamples
Groups, pages, events29groups, groupMembers, groupDues, pages, pageAuthors, events, eventRsvps, eventPosts
Games (non-football)267 stafli, 5 djamm, 5 thjodhatid, 4 geoGame, trophyTransactions
World Cup23matches, predictions, scores, leagues, bracket slots, sponsors
Notifications and growth23notifications, 8 hook tables, 7 churnRisk tables, broadcastCampaigns
Analytics and admin23hourly, daily, weekly and monthly actives, retention, kpiSnapshots, agentThreads, pdfReports
Posts and content22posts, postCounters, likes, comments, pollVotes, dailyUpdates, musicClips
Tilboð21businesses, tilbodSlots, tilbodOffers, tilbodClaims, tilbodClubMembers, tilbodClubInvoices
Users, auth, moderation18users (a 318-line document), consentLogs, blockedUsers, reports, moderationLog, excludedUsersCache
Social graph16friendships, userFriends, profileViews, userLocations, userGraphEmbeddings
Feed and ML15feedEntries, userFeedHealth, feedImpressionLog, feedRankerWeights, experiments
Premier League15matches, fixture details, predictions, season-namespaced scores, league invites
Markaðstorg13markadListings, markadTrades, markadReviews, markadSavedSearches, svaediAccess
Leigumarkaður12leigaListings, leigaApplications, leigaOwnershipVerifications, leigaStadfang
Messaging7conversations, conversationParticipants, conversationMessages, chatEvents
Dating7datingProfiles, datingSwipes, datingFeedEntries, datingTopPicks, datingMatches
Automated content5automatedPages, newsItems, automatedDrafts, spurningQuestions
Counters3merchInterest, merchHypeCounter, tilbodCounter

The Better Auth component adds five tables of its own: user, session, account, verification and jwks.

How data moves

Five design decisions shape the backend.

Reactive subscriptions and polled hooks

Convex's default is a WebSocket subscription: the client subscribes to a query and receives a new result whenever a write changes it. The mobile app uses that in 473 places. But a subscription on a large list re-sends the whole result on every write, and the home feed, stories, groups and explore are large lists with constant writes. Those are read through custom polled hooks instead. There are three of them, plain, paginated and delta, used 120 times. A polled hook calls the query once and refetches on demand. It times out at 20 seconds, retries with a backoff from 2 to 30 seconds, refetches when the app comes to the foreground, and drops stale responses through a generation counter.

Reactive subscriptions and polled hooksOn the left, a reactive WebSocket subscription: Convex re-sends the full result to the client on every write, which is expensive for large lists. On the right, a polled hook: the client runs the query once and refetches on demand, with a 20 second timeout, 2 to 30 second backoff, a refetch on foreground, and a generation counter that drops stale replies. The home feed, stories, groups, explore and other large lists use the polled hooks.Reactive subscriptionClientWebSocket subscriptionConvexre-sends the result onevery writeCostlarge lists re-send infull on every write, whichis bandwidthPolled hookClientquery once, refetch ondemandConvexanswers one queryUsed forhome feed, stories,groups, explore and otherlarge lists
Reactive subscriptions are the default. The large lists use polled hooks, which give up instant updates to save bandwidth.

The polled hooks exist to save bandwidth on phones. Instant updates on the large lists were not worth their cost.

Precomputed feeds

Posting does not write to one table that everyone reads. It schedules a fan-out into a per-user feedEntries table, in waves. Wave zero writes the author's own entry and starts three discovery seeders in parallel: 1,500 random users, up to 2,000 taste-matched users found by vector search on the author's engagement embedding, and everyone active in the last 24 hours, in batches of 200. Friends receive the post in up to 12 waves five minutes apart, starting with the 40% the like-predictor ranks highest. Crossing a 4.5% friend engagement rate unlocks full friend reach.

Feed fan-out in wavesA new post writes the author's own feed entry immediately. Wave zero runs three discovery seeders in parallel: 1,500 random users, up to 2,000 taste-matched users found by vector search, and everyone active in the last 24 hours in batches of 200. Friends receive the post in up to 12 waves five minutes apart, starting with the 40% the like-predictor ranks highest; a 4.5% friend engagement rate unlocks full reach. Each delivery is a row in feedEntries with a score of creation time plus a trending boost. Reading is an index range scan of the top 80 unseen entries, re-ordered by the learned ranker.New postthe author's own feed entry is writtenat onceWave 0: three discovery seeders in parallel1,500 random users; up to 2,000taste-matched users found by vectorsearch on the author's engagementembedding; everyone active in the last24 hours, in batches of 200Friend waves: up to 12, five minutes apartthe first wave is the 40% of friendsthe like-predictor ranks highest; a4.5% friend engagement rate unlocksfull friend reachfeedEntries: one row per user and postscore is creation time plus a trendingboost of 1 to 250 hours, plus 4 hoursfor friend posts; marking seen makesthe score negativeRanked readindex range scan of the top 80 unseenentries, re-ordered by the learnedranker
Every user's feed is a precomputed list of rows. Marking a row seen makes its score negative, so the index range skips it.

An entry's score is its creation time plus a trending boost of one to 250 hours, plus four hours for friend posts. Marking an entry seen subtracts a large constant, so the score goes negative and the index range excludes it. Reading the feed is an index range scan, and the learned ranker re-orders the top 80 candidates per session. The trending boost, the viral reseeding job and the training of that ranker are on the feed ranker page.

A feed-health job keeps each user at a target of 300 unseen entries. It reserves 40% of seed slots for the users with the fewest entries, refills from popular posts when a feed drops below 50, and cleans entries older than 48 hours every hour.

Media uploads

Images go to Cloudflare R2 through presigned PUTs. The device compresses first, with presets per use: feed images at 1,200 px and quality 0.85, avatars at 400 px, full-size at 1,600 px, comment images at 600 px, with an optional blurhash. After the upload, a metadata sync and a size check run before the key is trusted. Video goes to Cloudflare Stream through resumable tus uploads in 10 MiB byte-range chunks, with a 600-second cap and 20 uploads per day; the thumbnail goes to R2. Playback URLs are signed and cached on the device for 23 hours.

Media upload pathThe device compresses images to preset sizes and splits video into 10 MiB chunks. Images, audio and print files go to Cloudflare R2 by presigned PUT. Video goes to Cloudflare Stream by resumable tus upload, which transcodes it and serves HLS. The Convex backend signs the upload URL first and records the key afterwards, after a metadata sync and a size check. It never receives the bytes.Devicecompresses images to presets; splitsvideo into 10 MiB chunksCloudflare R2images, audio and printfiles, by presigned PUTCloudflare Streamvideo by resumable tusupload; transcode and HLSConvex backendsigns the upload URL, then records thekey after a metadata sync and sizecheck; never receives the byteskeykey
The backend signs uploads and records keys; the bytes go directly to Cloudflare. Playback URLs are signed and cached on the device for 23 hours.

The backend signs the upload and records the result. It never handles the bytes, so function execution time and memory are not part of the media path.

Chat encryption keys

Each conversation, direct or group, carries a 256-bit data key that is stored wrapped by a Google Cloud KMS key. Messages are encrypted with AES-256-GCM under that data key, with a random 96-bit IV, and stored as base64 ciphertext plus the IV. Clients fetch keys through an authenticated query, batched so that one KMS token covers a batch, and do the cryptography locally with Web Crypto or noble ciphers. The server can decrypt for notification previews and migrations, and a batched migration encrypted the message history that predated the scheme.

Chat encryption keysGoogle Cloud KMS holds the key-encryption key. Each conversation holds a 256-bit data key stored only in wrapped form. A client fetches the unwrapped key over an authenticated query, batched with one KMS token per batch, then encrypts and decrypts messages locally with AES-256-GCM and a random 96-bit IV. The messages table stores base64 ciphertext and IV. The server can decrypt for notification previews and migrations.Google Cloud KMSthe key-encryption key, keyring ineurope-north1Conversationholds a 256-bit data key, stored onlyin wrapped formClientfetches the unwrapped key over anauthenticated query, then encrypts anddecrypts locally with AES-256-GCM anda random 96-bit IVMessagesbase64 ciphertext and IV; the servercan decrypt for notification previewswraps the data keyunwrapped, one KMS token per batchciphertext and IV
The database stores the data key wrapped. It is unwrapped for the client over an authenticated query and, when needed, inside a function.

The keyring lives in europe-north1, and its setup, from keyring to service account, is one of the few runbooks in the repository. Typing status lives in its own table so that typing indicators never contend with message writes, and unread counts come from an aggregate rather than a scan.

Counting with aggregates

Fifty-five aggregate components maintain counts and ranks: users, posts, unread messages, trophies, scores, impressions. An aggregate is a balanced tree that answers count and rank queries in logarithmic time and is updated on every write, so admin dashboards and leaderboards do not scan tables. The migrations module holds roughly 20 aggregate backfills, one per counter that was introduced after the data it counts.

Crons

Eighty-eight scheduled jobs run the product's background work, from ranking to weather posts.

CategoryJobsExamples
Feed, ranking, ML training16rank every 4 minutes, trending groups every 30 minutes, nightly ranker and item2vec, Node2Vec daily
Automated content13RSS poll every 30 minutes, weather at 07:20, digests at 07:50 and 16:50, question at 20:00
Notifications and growth11hooks every 2 hours, churn dispatch at 18:00, profile-view upsell at 19:00
Tilboð11seed slots at 00:05, activate offers at 00:10, rotate PINs at 05:00, monthly club invoices
Games10Premier League fixture sync every minute in match windows, geo round at 23:00, challenge close at 23:55
Markaðstorg8expiry and reminders hourly, saved-search alerts every 15 minutes
Social housekeeping7event reminders every 15 minutes, closeness decay on Monday, stale locations at 03:00
Dating4refill feeds every 6 hours, top picks at 11:00, Elo on Monday
Leigumarkaður4expiry and reminders hourly
Admin and analytics4KPI snapshot every 6 hours, cohort retention on Monday

Six more crons are commented out: one legacy reminder and five World Cup jobs retired after the tournament. Two jobs are registered after the default export and still take effect.

External services

ServiceUsed for
KenniIcelandic eID sign-in
Cloudflare R2images, audio, print files
Cloudflare Streamvideo upload, transcode, HLS
Google Cloud KMSkey-wrapping for chat encryption
RevenueCatSlapp+ entitlement webhooks
Expo Pushall notifications
Resendmerchant and school email
OpenAI via the AI SDKnews, parsing
API-Footballfixtures and live scores
Shopify and Printfultee checkout and fulfilment
Google Places and Street Viewaddresses, the geo game
Icelandic dataVeðurstofa, the HMS address registry, BÍN, RSS news, Seðlabanki

AI inside the backend

Structured parsing turns text or screenshots into rental listings, a photo into a marketplace draft with the price deliberately left out, and rental listings into five other languages at write time.

Rate limits

Sixty-nine named limits protect the write paths, as token buckets or fixed windows. Examples: 20 posts per hour, 1,000 messages per minute, 200 swipes per hour, 20 free dating likes per day, 20 reports per hour.

Monorepo and tooling

The repository is a pnpm workspace under Turborepo, with one backend package shared by five apps through a path alias rather than a published module.

Package manager
pnpm with node-linker set to hoisted, which Metro requires.
Task runner
Turborepo with build, dev, lint, type-check and clean. No remote cache.
TypeScript
Strict everywhere, on different versions at the root and in mobile. There is no root tsconfig, and the shared presets exist but no app extends them.
Backend sharing
The backend package has no exports map. Apps alias its path directly and set transpilePackages. Metro stubs server-only Better Auth and non-generated backend imports to an empty module.
Lint and format
ESLint flat config with the Expo and Next presets. No Prettier, Biome, Husky or lint-staged.
Shared UI
None. shadcn components are copied per app: 46 in web, 46 in admin, 17 in the Tilboð portal. The auth client, Convex provider and utilities are near-identical copies.
Node
No engines field and no nvmrc. Only EAS pins Node.

The size of each area, the commit history and the release timeline are on the scale page. How all of this is deployed is on the operations page.