A Reactive Framework Built on a Streaming Database
Declare your schema, define queries that compile to materialized views, write mutations that run in transactions, and get live-updating data in React components. Surf compiles it all to SQL and streams changes to your UI.
Users expect live updates and streaming dashboards. The infrastructure to deliver them is complex out of proportion: REST APIs, caching layers, message brokers, cache invalidation logic, WebSocket plumbing, polling intervals tuned by intuition.
Surf collapses this stack. It's built on RisingWave, a streaming SQL database that's Postgres-compatible.
The core idea
Most real-time systems work backwards. The app queries the database on a timer, gets back rows, diffs them against the last result, and pushes changes to the client. The same query runs on the same schedule, whether anything changed or not.
Materialized views invert this. The database pre-computes the query result and maintains it as rows change. When you insert a row into the underlying table, only the affected portion of the view recomputes. Reads are constant-time because the answer is already there.
RisingWave takes this further: it exposes SUBSCRIBE cursors that push changes from materialized views as they happen, so you don't poll at all.
Surf wraps this in a framework. You write TypeScript, Surf compiles it to SQL, applies it to RisingWave, and streams changes to your React components over WebSocket.
Schema
You define tables with validators that serve two roles: enforcing TypeScript types at the boundary and mapping to SQL column types:
import { defineSchema, defineTable, v } from "surf/server" const schema = defineSchema({ trades: defineTable({ marketId: v.string(), // TEXT side: v.literal("yes", "no"), // TEXT price: v.number(), // DOUBLE PRECISION amount: v.number(), // DOUBLE PRECISION createdAt: v.number(), // DOUBLE PRECISION }), })
Each table gets an auto-generated id: VARCHAR PRIMARY KEY. At startup, Surf compiles this to CREATE TABLE and applies it to RisingWave:
CREATE TABLE IF NOT EXISTS "trades" ( "id" VARCHAR PRIMARY KEY, "marketId" TEXT NOT NULL, "side" TEXT NOT NULL, "price" DOUBLE PRECISION NOT NULL, "amount" DOUBLE PRECISION NOT NULL, "createdAt" DOUBLE PRECISION NOT NULL );
One definition produces two outputs. TypeScript types flow through to your queries and mutations. SQL types flow through to RisingWave.
Queries
Queries are declarative. You describe what data you want using a builder API, and Surf compiles it to a materialized view:
import { query, v } from "surf/server" const marketPrices = query({ args: {}, query: (q) => q .from(schema.tables.trades) .select("marketId", q.avg("price"), q.sum("amount"), q.count("id")) .groupBy("marketId"), })
This compiles to:
CREATE MATERIALIZED VIEW IF NOT EXISTS "_surf_marketPrices" AS SELECT "marketId", AVG("price"), SUM("amount"), COUNT("id") FROM "trades" GROUP BY "marketId";
RisingWave maintains this view as data changes. When you insert a trade, the average price for that market updates in the view. Reading it is a SELECT * with no aggregation at query time.
Queries can also take arguments:
const marketTrades = query({ args: { marketId: v.string() }, query: (q) => q .from(schema.tables.trades) .where({ marketId: q.arg("marketId") }) .orderBy("createdAt", "desc") .limit(50), })
Arguments change how the query compiles. Static filters (no args) bake into the materialized view definition. Arg-based filters apply at subscription time: the MV contains the superset of data, and Surf's subscription manager filters per-subscriber. This keeps the number of materialized views small while supporting parameterized queries.
The builder supports from, where, select, orderBy, limit, join, groupBy, and aggregations (count, sum, avg, min, max).
Mutations
Mutations are imperative. Each one runs inside a database transaction:
import { mutation, v } from "surf/server" const placeTrade = mutation({ args: { marketId: v.string(), side: v.literal("yes", "no"), price: v.number(), amount: v.number(), }, handler: async (ctx, args) => { return ctx.db.insert("trades", { ...args, createdAt: Date.now(), }) }, })
The ctx.db interface provides insert, update, delete, get, and query().where().collect(). All operations run within a single transaction. If the handler throws, it rolls back.
Surf validates arguments against the mutation's schema before running the handler. The same validators that define your table columns validate your mutation inputs.
The runtime
When the server starts, three things happen:
- Schema application. Surf compiles your table definitions to
CREATE TABLEstatements and your queries toCREATE MATERIALIZED VIEWstatements, then runs them against RisingWave. - Query registration. Each query's AST is registered with the subscription manager, which extracts arg-based filters and stores them for per-subscriber filtering.
- WebSocket server. A
wsserver starts listening for client connections.
The data flow
From mutation to UI update:
- Client calls
useMutation("placeTrade")({ marketId: "btc", ... }) - SurfClient sends WebSocket message to SurfServer
- Server validates args, runs handler in a transaction
- Handler calls
ctx.db.insert("trades", data), RisingWave commits the row - RisingWave incrementally updates the materialized view
- Subscription manager detects the change via
SUBSCRIBEcursor or polling - Server fetches updated data, filters by each subscriber's args
- Server pushes update over WebSocket to all subscribed clients
useQuery()re-renders with new data
The subscription manager supports two modes: SUBSCRIBE cursors (RisingWave pushes changes as they happen) and polling fallback (for environments where SUBSCRIBE isn't available). Both are transparent to the client.
The protocol
All client-server communication flows through a typed WebSocket protocol:
| Direction | Message | Purpose |
|---|---|---|
| Client → Server | subscribe | Start receiving query results |
| Client → Server | unsubscribe | Stop receiving updates |
| Client → Server | mutate | Execute a mutation |
| Server → Client | subscribe:snapshot | Initial query result set |
| Server → Client | subscribe:update | Changed data |
| Server → Client | mutate:result | Mutation return value |
| Server → Client | subscribe:error / mutate:error | Errors |
A single persistent WebSocket connection replaces REST and HTTP request/response cycles for data.
React
The React integration is a context provider and two hooks:
import { SurfClient } from "surf/client" import { SurfProvider, useQuery, useMutation } from "surf/react" const client = new SurfClient({ url: "ws://localhost:8080" }) function App() { return ( <SurfProvider client={client}> <TradingTerminal /> </SurfProvider> ) } function TradingTerminal() { const prices = useQuery("marketPrices", {}) const trades = useQuery("marketTrades", { marketId: "btc-2025" }) const placeTrade = useMutation("placeTrade") return ( <div> <button onClick={() => placeTrade({ marketId: "btc-2025", side: "yes", price: 0.65, amount: 500, })}> Buy Yes </button> {prices?.map((p) => ( <div key={p.marketId}> {p.marketId}: {p.avg} </div> ))} </div> ) }
useQuery subscribes to a live query. When the server pushes an update, the component re-renders. When the component unmounts, it unsubscribes. useMutation returns an async function that sends a mutation over WebSocket and resolves with the result.
SurfClient is framework-agnostic. It manages a WebSocket connection and tracks subscriptions. The React layer is 47 lines of code.
Design decisions
One MV per query, not per subscriber
A naive approach would create a materialized view for each unique set of subscription arguments. Ten users watching ten different markets means ten materialized views, and that doesn't scale.
Surf creates one MV per query definition. The MV contains all the data. When a subscriber connects with specific args ({ marketId: "btc-2025" }), the subscription manager filters the MV output server-side before pushing to that client. The number of materialized views stays proportional to query definitions, not active subscribers.
Validators map to both TypeScript and SQL
The v.string(), v.number(), v.boolean() validators carry a sqlType property (TEXT, DOUBLE PRECISION, BOOLEAN) that the schema compiler reads when generating CREATE TABLE statements. One definition drives both the type system and the database schema. When you add a column, TypeScript types and SQL schema update together.
Framework-agnostic client
SurfClient has no React dependency. It's a WebSocket client that speaks the Surf protocol. The React integration (useQuery, useMutation, SurfProvider) is a 47-line layer on top. The same client works in Node.js scripts or other frameworks.
Transactions for mutations
Mutation handlers run inside a BEGIN / COMMIT pair. If the handler throws, it rolls back. Mutations are the only write path, and they should be atomic. There's no ctx.db.rawQuery() because all writes go through the structured API, within a transaction.
The demo
The prediction market demo makes the architecture tangible. It runs two identical React frontends against the same RisingWave instance. One server polls the materialized view every 200ms. The other polls the raw table every 5 seconds. Same useQuery("marketPrices") calls, same React components. The only difference is server-side query resolution.
A trade generator (or a live Polymarket WebSocket feed) pumps trades into RisingWave. The MV-backed app reads a pre-computed view in constant time. The re-query app re-runs the full aggregation against the growing table on each poll.
MV-backed app (left) vs re-query app (right) under continuous trade load
Benchmark dashboard — live latency and throughput under load (4x speed)
benchmark/trade-generator.ts | | INSERT trades (50/sec) v RisingWave | |-- Surf Server :8081 (MV, 200ms poll) --> React app :3002 | '-- Surf Server :8082 (re-query, 5s poll) --> React app :3003
The demo proves that the simpler architecture (let the database maintain the answer, read it) is also the faster one.
What this is
Surf is ~1250 lines of TypeScript, a framework for building applications where data should be live. Prediction markets and dashboards, anything where you'd otherwise poll or invalidate caches.
Declare reads, describe writes. RisingWave keeps everything in sync.