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
| Client | Notes |
|---|---|
| Mobile | Expo, 856 files, 257k lines, WebSocket plus polled queries |
| Web | Next.js, 93 pages, server-rendered previews through the HTTP client |
| Admin | Next.js, 42 pages, isAdmin gate |
| Tilboð | Next.js, 15 pages, merchant auth prefix |
| Shop | Next.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
| Component | Notes |
|---|---|
| 262 modules | 188 top-level plus 6 subdirectories, 154k lines |
| 278 tables | 743 indexes, 12 search indexes, 4 vector indexes at 128 dimensions |
| 88 crons | 41 daily, 39 interval, 7 weekly, 1 monthly |
| 63 components | 55 aggregates, betterAuth, rateLimiter, r2, resend twice, push, workflow |
| 69 rate limits | token bucket and fixed window |
| 8 Node-runtime files | email, PDF, photo hashing, address sync |
| 62 environment variables | read 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
| Domain | Modules | Representative files |
|---|---|---|
| Tilboð business offers | 21 | tilbod, tilbodAdmin, tilbodOffers, tilbodClaims, tilbodClubs, tilbodClubBilling, tilbodPeriod |
| Games and seasonal | 20 | stafli, geoGame, dailyChallenge, gameTrophies, djamm, thjodhatid, worldCup*, premierLeague* |
| Leigumarkaður rentals | 16 | leigaListings, leigaAI, leigaTrust, leigaPhotoHash, leigaTranslations, leigaAddressesSync |
| Notifications and growth ML | 14 | pushNotifications, hookNotifications, notificationRanker*, churnRisk*, postPropensity* |
| Markaðstorg marketplace | 14 | markadListings, markadTrades via markadInterests, markadReputation, markadRisk, markadAI |
| Feed and ranking | 12 | feed, feedRanked, feedRanker*, ranking, feedHealth, embeddings*, abTesting |
| Social graph | 12 | friends, userFriends, friendRanker*, graphSuggestions, node2vecTraining, tengsl, direction |
| Admin and analytics | 11 | admin, kpi, cohorts, featureImportance, adminARPU, adminMLAnalytics |
| Posts and content | 10 | posts, comments, likes, postCounters, drafts, dailyUpdates, linkPreviews |
| Infra and ops | 10 | schema, crons, http, migrations, appConfig, rateLimits, svaediAccess |
| Automated content | 9 | contentIngest, contentFrettir, contentVedrid, contentSkjalfta, contentFrodleikur, spurningDagsins |
| Monetisation | 8 | subscription, loyalty, slappPlusGroup, referrals, shop |
| Groups, pages, events | 7 | groups, groupPosts, groupDues, pages, events, eventPosts, schoolEmails |
| Auth and users | 6 | auth, users, onboarding, consent, utlis |
| Media and storage | 6 | r2Storage, storageHelpers, stream, videoLimits, instagramImport, musicClips |
| Moderation | 4 | blocking, moderation, reports, excludedUsers |
| Messaging | 3 | messaging, chatEvents, encryption |
| Dating, search, AI | 5 | dating, 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
| Domain | Tables | Examples |
|---|---|---|
| Groups, pages, events | 29 | groups, groupMembers, groupDues, pages, pageAuthors, events, eventRsvps, eventPosts |
| Games (non-football) | 26 | 7 stafli, 5 djamm, 5 thjodhatid, 4 geoGame, trophyTransactions |
| World Cup | 23 | matches, predictions, scores, leagues, bracket slots, sponsors |
| Notifications and growth | 23 | notifications, 8 hook tables, 7 churnRisk tables, broadcastCampaigns |
| Analytics and admin | 23 | hourly, daily, weekly and monthly actives, retention, kpiSnapshots, agentThreads, pdfReports |
| Posts and content | 22 | posts, postCounters, likes, comments, pollVotes, dailyUpdates, musicClips |
| Tilboð | 21 | businesses, tilbodSlots, tilbodOffers, tilbodClaims, tilbodClubMembers, tilbodClubInvoices |
| Users, auth, moderation | 18 | users (a 318-line document), consentLogs, blockedUsers, reports, moderationLog, excludedUsersCache |
| Social graph | 16 | friendships, userFriends, profileViews, userLocations, userGraphEmbeddings |
| Feed and ML | 15 | feedEntries, userFeedHealth, feedImpressionLog, feedRankerWeights, experiments |
| Premier League | 15 | matches, fixture details, predictions, season-namespaced scores, league invites |
| Markaðstorg | 13 | markadListings, markadTrades, markadReviews, markadSavedSearches, svaediAccess |
| Leigumarkaður | 12 | leigaListings, leigaApplications, leigaOwnershipVerifications, leigaStadfang |
| Messaging | 7 | conversations, conversationParticipants, conversationMessages, chatEvents |
| Dating | 7 | datingProfiles, datingSwipes, datingFeedEntries, datingTopPicks, datingMatches |
| Automated content | 5 | automatedPages, newsItems, automatedDrafts, spurningQuestions |
| Counters | 3 | merchInterest, 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.
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.
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.
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.
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.
| Category | Jobs | Examples |
|---|---|---|
| Feed, ranking, ML training | 16 | rank every 4 minutes, trending groups every 30 minutes, nightly ranker and item2vec, Node2Vec daily |
| Automated content | 13 | RSS poll every 30 minutes, weather at 07:20, digests at 07:50 and 16:50, question at 20:00 |
| Notifications and growth | 11 | hooks every 2 hours, churn dispatch at 18:00, profile-view upsell at 19:00 |
| Tilboð | 11 | seed slots at 00:05, activate offers at 00:10, rotate PINs at 05:00, monthly club invoices |
| Games | 10 | Premier League fixture sync every minute in match windows, geo round at 23:00, challenge close at 23:55 |
| Markaðstorg | 8 | expiry and reminders hourly, saved-search alerts every 15 minutes |
| Social housekeeping | 7 | event reminders every 15 minutes, closeness decay on Monday, stale locations at 03:00 |
| Dating | 4 | refill feeds every 6 hours, top picks at 11:00, Elo on Monday |
| Leigumarkaður | 4 | expiry and reminders hourly |
| Admin and analytics | 4 | KPI 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
| Service | Used for |
|---|---|
| Kenni | Icelandic eID sign-in |
| Cloudflare R2 | images, audio, print files |
| Cloudflare Stream | video upload, transcode, HLS |
| Google Cloud KMS | key-wrapping for chat encryption |
| RevenueCat | Slapp+ entitlement webhooks |
| Expo Push | all notifications |
| Resend | merchant and school email |
| OpenAI via the AI SDK | news, parsing |
| API-Football | fixtures and live scores |
| Shopify and Printful | tee checkout and fulfilment |
| Google Places and Street View | addresses, the geo game |
| Icelandic data | Veð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.