Pixeltable vs Convex
Same video-intelligence app, two implementations. Pick Convex when reactivity is the point — the UI re-renders on write. Pick Pixeltable when the pipeline is the product. A REST-shaped benchmark is Convex’s worst event; discount this column accordingly.
pip install 'pixeltable[serve]'The trade
| Side | Pixeltable | Convex |
|---|---|---|
| At a glance |
|
|
What we measured
Same app. Convex wins install, ingest, and transcript search. Pixeltable wins in-platform media. REST is Convex’s worst event.
| Feature | Pixeltable | Convex |
|---|---|---|
| Local install | pip install; large Python deps (torch, whisper, sentence-transformers) | npx convex dev — no account, no Docker, easiest of the three |
| Total code for this app | 129 lines, 1 file | 681 lines (429 + 252 compute-service), 7 files |
| ffmpeg, Whisper, CLIP | Computed columns, same process | compute-service; the Convex runtime cannot run them |
| Writes from an action | Insert is a row; computed columns run | An action cannot write; 109 lines of mutations in videos.ts |
| HTTP API | add_query_route derives the signature; malformed requests are 422 | Five http.route blocks. Validators sit inside the function; a failure is a 500 |
| Reactive client | None here — you would write polling or a websocket | The reason most teams pick Convex; discarded by this REST contract |
| Vector search result limit | No ceiling in this implementation | vectorSearch clamps to 256 |
| Ingest, 20 videos / 10 min | 66.7s, 9.04× realtime; first video 5.3s | 53.7s, 11.25× realtime; first video 3.2s |
| Transcript search p50 | 18.5ms | 11.8ms |
| Add a derived column (lines) | 1 line, 1 file | 53 lines, 2 files; reverting needs a second migration |
| Processing for any writer | Yes — the pipeline is the schema | No, unless you add a scheduled action |
| Vendor checker in CI | ruff only | @convex-dev/eslint-plugin and tsc --noEmit against generated code |
All three beat realtime on this laptop
20 videos, 10 minutes of footage, CPU, local models — not Cloud. Bold is best on that row.
Ingest
| Feature | Pixeltable | Convex |
|---|---|---|
| Wall time | 66.7s | 53.7s |
| Faster than realtime | 9.04x | 11.25x |
| First video | 5.3s | 3.2s |
| Median video | 3.1s | 2.4s |
Search
| Feature | Pixeltable | Convex |
|---|---|---|
| Frame search p50 | 39.0ms | 26.6ms |
| Frame search p95 | 42.9ms | 40.7ms |
| Transcript search p50 | 18.5ms | 11.8ms |
| Transcript search p95 | 21.6ms | 17.7ms |
20 videos, 603.7 seconds of footage, 603 frames, 70 transcript chunks, one laptop, CPU, local models (Pixeltable 0.7.8). All three finished 20 of 20, all faster than realtime. Supabase led ingest (11.64× vs Pixeltable’s 9.04×); frame search is 26–39ms and most of that is embedding the query, not the index. 603 vectors is not a million-row benchmark, hosted Pixeltable Cloud was not in this run, and none of this is a cost comparison.
Adding a column to live data
One computed title-embedding index on a populated catalog. Lines compound; these seconds do not.
| Feature | Pixeltable | Convex |
|---|---|---|
| Schema change | 3.88s | 5.98s |
| Backfill | same step | 1.39s |
| Total | 3.88s | 7.37s |
| Lines written | 1 | 53 |
| Files touched | 1 | 2 |
- Supabase is the fastest in wall time. At two dozen rows the backfill is noise; anyone quoting these seconds as a scaling result is quoting noise.
- Reverting is not symmetric. Convex needs a second migration (17 of its 53 lines) because pushing a schema that no longer declares the field is rejected while documents still carry it.
- After pxt schema update, an insert against the already-registered route answers 409 until pxt service update. Reads keep working. The other two resolve the table on every request.
- Backfill time is the part that scales, and this corpus cannot show it. Pixeltable’s backfill is work proportional to the rows that changed; a backfill script is work proportional to the table.
Ingest a video
Insert a video. Frames, audio, transcripts, embeddings, and scenes have to exist after that. On Pixeltable they are the schema. On the other two they live in the ingest path and in a second service.
Pixeltable
class Videos(TableModel, name='videos'):video: pxt.Videotitle: pxt.Stringaudio = extract_audio(video, format='mp3')duration_sec = pxtf.video.get_duration(video)scenes = video.scene_detect_content(threshold=8.0)class Frames(TableModel, name='frames', base=Videos,iterator=frame_iterator(Videos.video, fps=1.0)):still = pxtf.image.resize(frame, (320, 180))__indexes__ = [pxt.EmbeddingIndex(frame, embedding=VISUAL)]class Chunks(TableModel, name='chunks', base=Videos,iterator=audio_splitter(Videos.audio, duration=10.0)):transcript = transcribe(audio_segment, model='base.en').text.astype(pxt.String)__indexes__ = [pxt.EmbeddingIndex(transcript, embedding=SEMANTIC)]Videos.insert([{'video': 'lecture.mp4', 'title': 'CS101'}])
Convex
const { frames } = await compute("/extract-frames", { video_url, fps: FRAME_FPS });const { embeddings } = await compute("/embed-clip", { images_b64: frames });const frameRows = await Promise.all(frames.map(async (b64, i) => ({frameIdx: i,imageStorageId: await ctx.storage.store(new Blob([decodeBase64(b64)])),embedding: embeddings[i],})));await ctx.runMutation(internal.videos.insertFrames, { videoId, rows: frameRows });
Search frames
Find frames of a whiteboard. Pixeltable asks the index. The other two embed the query themselves, then join or fetch rows in a second step.
Pixeltable
sim = Frames.frame.similarity(string=query)return (Frames.order_by(sim, asc=False).limit(limit).select(frame_url=Frames.still,frame_idx=Frames.pos,video_title=Frames.title,similarity=sim,))
Convex
const { embeddings } = await compute("/embed-clip", { texts: [query] });const hits = await ctx.vectorSearch("frames", "by_embedding", {vector: embeddings[0],limit: clamp(limit), // vectorSearch is 1-256});return await ctx.runQuery(internal.search.framesByIds, {ids: hits.map((h) => h._id),scores: hits.map((h) => h._score),});
Serve over HTTP
Expose search over HTTP. Pixeltable derives the route from the query. Supabase puts five paths in one function. Convex writes five REST routes only because this contract asked for REST.
Pixeltable
api = FastAPIRouter(name='api')api.add_insert_route(Videos, path='/videos', inputs=[Videos.video, Videos.title], background=True)api.add_query_route(path='/videos', query=list_videos, method='get')api.add_query_route(path='/search/frames', query=search_frames, method='post')api.add_query_route(path='/search/transcripts', query=search_transcripts, method='post')
Convex
http.route({path: "/search/frames",method: "POST",handler: httpAction(async (ctx, req) =>guarded(async (r) => {const body = await readJson(r);return json(await ctx.runAction(api.search.searchFrames, {query: requireString(body.query, "query"),limit: readLimit(body.limit),}));})(req)),});
When to choose which platform
Choose Pixeltable when
- The pipeline is the product
Media in, models and retrieval out, in one Python file. Processing belongs to the table, so a row written from a shell is processed the same way as a row written over HTTP.
- You do not want to operate a second runtime for ffmpeg
The Convex runtime cannot execute ffmpeg. Three compute-service endpoints have no hosted-API substitute. That extra service is most of the orchestration hops.
Choose Convex when
- Reactivity is the point
Build the same app with Convex’s reactive client instead of five REST endpoints and http.ts disappears along with both taxes. The client re-renders on write for free, and mutations are transactional.
- You want the easiest local backend
npx convex dev gives a working local backend with no account and no Docker. That is the easiest install of the three, and it is not close.
Making the right choice
Discount the REST column
- videos.ts (109 lines) and http.ts (90 lines) are taxes this contract imposes, not Convex’s native shape.
- Reproduce the numbers: https://github.com/pixeltable/pixeltable-vs-supabase-vs-convex
Frequently asked questions
One file. The whole pipeline.
Declare the tables. Apply the schema. Insert a row. Serve the same file.