AppSync Events for Next.js: the Supabase Realtime you assemble yourself
MCA Benches runs a live draft. Captains take turns picking players, there’s a countdown on each pick, and an OBS source somewhere is streaming the board to whoever’s watching. When I wrote about it I admitted what’s underneath: a polled version counter, one cheap row read a second. Not SSE, because Lambda response streaming through OpenNext is fragile enough that I didn’t want a live broadcast leaning on it.
For ten captains that’s still the right call. I’ve been curious for a while about what I’d replace it with, and this is that design worked out on paper. I haven’t built it. Where the paper fought back is the interesting part, and I’ve marked those places as they come.
The comparison everyone reaches for is this one, and it’s fair:
supabase
.channel('draft-42')
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'draft_picks', filter: 'draft_id=eq.42' },
(payload) => addPick(payload.new))
.subscribe()
Four lines, and the database pushes to the browser with row level security enforced on the way out. That’s the bar. On AWS it isn’t four lines, and I wanted to know what it looks like & if it actually has any advantages.
Supabase Realtime is three products
“Supabase Realtime” is three separate products sharing one client object, and they have very different characters.
| What it does | AWS equivalent | |
|---|---|---|
| Broadcast | ephemeral pub/sub on a topic | AppSync Events, near enough 1:1 |
| Presence | who’s in this channel, synced | build it, it’s about 30 lines |
| Postgres Changes | subscribe to table rows, RLS enforced | nothing. Assemble it from a change stream |
Postgres Changes is the one doing the work in that four-line example, and the one with no AWS counterpart. Which looks like a downgrade until you read Supabase’s own guidance on it.
They recommend Broadcast over Postgres Changes for most use cases, and recommend leaving Postgres Changes entirely above roughly 3,000 concurrent subscribers on the same changes. The reason is structural: every change is filtered per connected client, and RLS is re-checked per client. Fine with ten users, not with ten thousand.
Their answer at scale is Broadcast from Database. You write an AFTER INSERT/UPDATE/DELETE trigger that calls realtime.broadcast_changes(), hand-shape the payload, and push it to a topic. Authorization moves to an RLS policy on realtime.messages, so it becomes a policy about topics instead of about rows.
That is exactly the architecture I will be going with for the rest of this post, with the trigger swapped for a change stream and the topic string swapped for a channel in AppSync Events. You are starting where Supabase eventually sends you, and skipping the four-line version you would have had to migrate off anyway.
To be clear: This is going to be more complex than the Supabase version. I want to find out how much & what advantages it brings with it.
What AppSync Events is
AppSync Events is a managed service for websocket connections. With it, you can create an Event API that owns one or more channel namespaces, and a namespace is the first segment of a channel path. Any channel starting /orders/ belongs to the orders namespace. Channels themselves are ephemeral and created on demand, so /orders/acme/42 can just be created on the fly for free.
You publish over HTTP (POST https://<domain>/event) or over websocket, and subscribe over the socket only, with wildcards. Two handlers run on AppSync’s JavaScript runtime: onPublish filters and transforms, onSubscribe authorizes. Since April 2025 those handlers can reach data sources directly: Lambda, DynamoDB, Amazon RDS, EventBridge, OpenSearch, Bedrock, and plain HTTP endpoints. An HTTP data source can call any AWS service API, so the list is less closed than it looks. Lambda also has a direct integration that needs no handler code at all, where AppSync calls your function with the request context and uses what it returns.
The numbers that decide your architecture:
| max event size | 240 KB |
| events per publish request | 5 |
| HTTP publish rate | 10,000 events/s per API (adjustable) |
| WebSocket publish rate | 25 req/s per client connection |
| outbound messages | 1,000,000/s per API (adjustable) |
| connection requests | 2,000/s per API (adjustable) |
| billing | per 5 KB delivered, per subscriber |
| persistence and replay | none, at all |
The five-event cap, the per-subscriber billing and the connection rate all come back later, because all three end up shaping the design.
Auth can be configured separately for connect, publish and subscribe, choosing per operation from API_KEY, AWS_IAM, AWS_LAMBDA, OPENID_CONNECT and AMAZON_COGNITO_USER_POOLS. So the backend publishes with SigV4 while the browser holds a credential that can only ever subscribe. There’s no shared anon key that can also write.
The channel is the authorization boundary
Supabase authorizes rows. AppSync Events authorizes channels. There’s no way to express “this subscriber may see rows where tenant_id = $current_tenant” as a predicate over rows. You push that distinction up into the channel path and let onSubscribe check it:
/drafts/{draftId} the board everyone watching can see
/orders/{tenantId} a tenant-wide feed
/orders/{tenantId}/{orderId} one order
/user/{userId}/notifications strictly personal
The handler is small, and because channels are free to create you can afford to be as granular as your authorization model:
import { util } from '@aws-appsync/utils'
export function onSubscribe(ctx) {
const tenant = ctx.identity.claims['custom:tenant']
if (!ctx.info.channel.path.startsWith(`/orders/${tenant}/`)) {
util.unauthorized()
}
}
This is why the Supabase version has a 3,000 subscriber cliff and this one doesn’t. Authorization runs once per subscription instead of once per row per subscriber. The extra work you’re being asked to do up front is the same work that makes it scale, which took me a while to see as anything other than an annoyance.
💡 Row level security fails closed. Channel level security fails open. Forget to write
onSubscribeand a client that subscribes to/orders/*gets every tenant’s orders, and nothing in the happy path will ever tell you.
What you can build with it
Pure broadcast, no database involved. Typing indicators, live cursors, selection highlights, reactions, “someone else is editing this record” warnings. These publish straight from the browser over the socket, and the 25 requests per second per connection is your budget, so throttle cursor movement to about 10 Hz and you’ll never see it. Supabase Broadcast already serves this category and it ports essentially unchanged.
Presence, which you build yourself. People assume this is the hard one to replace. It isn’t. Every client publishes a heartbeat to /room/{id}/presence every five seconds, and every client keeps a map of who it has heard from in the last fifteen. Ephemeral by construction, no table, self-healing when someone closes their laptop, and no disconnect handler to get wrong. If you need presence server-side, an onPublish handler with a DynamoDB data source and a TTL turns it durable.
Change-driven features, the Postgres Changes replacements. Order and job status, approval workflows where someone else’s action updates your screen, notification badges, live dashboards, and multi-tab sync for a single user. That last one is nearly free once the rest exists. Channel per entity, and the event originates in the stream consumer instead of in your app.
Streaming long-running work
This is the one I actually want it for, and the one where I think it’s clearly better than the alternative rather than just different.
Say you’re running an agent that takes ninety seconds and emits tool calls and partial output along the way. The instinct is to stream it from a route handler over SSE. On AWS that means holding a response open through CloudFront and Lambda for the full ninety seconds, which is fragile & a reload kills it. The user has no way back into a run that is still going.
Invert it. The work runs where it should, in Step Functions or a Lambda with a real timeout, and publishes progress to /runs/{runId}. The browser subscribes. The request that kicked it off returns in fifty milliseconds with a run ID.
flowchart LR Browser -- "POST /api/runs" --> Next[Next.js on Lambda] Next -- "runId" --> Browser Next --> SFN[Step Functions] SFN --> Agent[Agent Lambda] Agent -- "append step" --> DDB[(DynamoDB)] Agent -- "publish /runs/id" --> AE[AppSync Events] AE --> Browser AE --> Phone[Second device]
Now a reload rejoins the run instead of losing it, a second device can watch the same run, and two people can watch an agent work at once. The catch is the one that follows this whole post around: there’s no replay, so a client subscribing at second sixty sees nothing of the first sixty. The agent writes each step to DynamoDB and publishes, and the client fetches the steps so far before it subscribes. Getting that order wrong is the bug everyone writes once, and I’ll come back to it.
Where this is the wrong tool: anything that needs scrollback as a first-class feature (chat needs the database anyway), anything needing strict ordering across a stream, and interactive state under 100ms. That last one is IoT Core’s job.
Getting changes out of the database
Everything above that isn’t pure broadcast needs changes to leave the database on their own, without your application code remembering to announce them. There are three options and they aren’t interchangeable.
Aurora DSQL
DSQL change data capture went to preview in May 2026 and generally available on 8 July 2026, which is why I’m writing this now. It’s about six weeks old and I’ve had some time to play around with it now.
It streams committed changes to Kinesis Data Streams on a bring-your-own-target model: you create and own the stream, and DSQL assumes an IAM role you configure to write into it. Cluster, stream, role and caller all live in one account and Region. On a multi-Region cluster, a stream in any one Region captures writes from all Regions, so one stream is enough.
It captures every user table, with no per-table enable, so you filter downstream on source.schema and source.table. It applies write-set compaction, which means one record per row per transaction carrying the net final state, so updating a row three times inside one transaction still gives you one record (a per-statement audit trail therefore needs one statement per transaction). Delivery is at-least-once, with duplicates identified by primary key plus source.ts_ns.
And it’s UNORDERED. Not “usually ordered”. Records from different transactions arrive in any order, including two writes to the same primary key. source.ts_ns is the commit timestamp in nanoseconds and it’s how you re-establish order. source.txId groups a transaction.
There’s also one hard requirement: every participating table needs a primary key. Without one you can’t deduplicate and you can’t correlate a delete with the row it deleted.
DynamoDB Streams
The classic, and the contrast that makes DSQL’s semantics legible. Ordered per partition key, 24 hour retention, and NEW_AND_OLD_IMAGES hands you before and after in the same record. The Lambda event source mapping supports filter criteria, which is cheap to set up and worth doing early: drop the changes nobody subscribes to before you pay for an invocation. It’s the closest thing on offer to Supabase’s filter: option, and the one place where the AWS version is less work.
The alternative wiring, DynamoDB into Kinesis Data Streams, buys longer retention and larger fan-out but gives up per-key ordering and introduces duplicates.
Aurora PostgreSQL and MySQL
Most likely what you already have, so it deserves better than being filed under legacy. It starts the same way in every case, with logical replication through test_decoding or pglogical. What differs is who reads the slot.
There’s a symmetry here that will land if you’re coming from Supabase: this is the same write-ahead log Supabase Realtime reads. Same mechanism, different consumer. What Supabase runs as a managed Elixir process on the replication slot, you run as DMS, as Sequin, or as your own long-lived process.
DMS is the heavy answer. A replication instance is a thing you run, patch and pay for whether or not a single row changes, and a stuck slot will fill your writer’s disk while you’re asleep.
Sequin is the one worth knowing about here. A single open source container beside Postgres, no Kafka underneath it, and it has a Kinesis sink, so it drops into the same pipeline as everything else in this post without the consumer noticing the difference. It advertises strict ordering and exactly-once delivery through idempotency keys,
It also has a webhook sink. A transform function shapes the body, so you can emit AppSync’s { channel, events: [...] } directly. Headers are configurable, so x-api-key covers publish auth without SigV4 anywhere. Batch size is configurable, so you can cap it at five, and grouping by a column keeps a batch on one channel, which matters because the channel is one field for the whole request rather than per event. Point that at POST /event and you can skip the entire next section: no Kinesis, no consumer Lambda, no signing code.
Whichever of these you pick, this is the only route that works against a database you already have.
Which consumer problems you inherit
| Delivery | Ordering | Retention | Your consumer must | |
|---|---|---|---|---|
| Aurora DSQL CDC | at-least-once | unordered | Kinesis retention | dedupe, watermark per key |
| DynamoDB Streams | at-least-once | per partition key | 24h | dedupe |
| DynamoDB to Kinesis | at-least-once | unordered | Kinesis retention | dedupe, watermark per key |
| Aurora via DMS | depends on decoder | per slot, ordered | slot backlog | watch the slot |
| Aurora via Sequin | exactly-once, claimed | strict, claimed | its own | little, if the claims hold |
This helps us to get stuff out of the database, but for most of them something still has to move a record from the stream onto a channel.
Lambda on the stream, publishing over HTTP
The default. You sign with SigV4 against service appsync:
import { SignatureV4 } from '@smithy/signature-v4'
import { HttpRequest } from '@smithy/protocol-http'
import { Sha256 } from '@aws-crypto/sha256-js'
import { defaultProvider } from '@aws-sdk/credential-provider-node'
const signer = new SignatureV4({
credentials: defaultProvider(),
region: process.env.AWS_REGION!,
service: 'appsync',
sha256: Sha256,
})
async function publish(channel: string, events: unknown[]) {
const body = JSON.stringify({
channel,
// note: each event is a *stringified* JSON value, not an object
events: events.map((e) => JSON.stringify(e)),
})
const req = new HttpRequest({
method: 'POST',
protocol: 'https:',
hostname: HOST,
path: '/event',
headers: { host: HOST, 'content-type': 'application/json' },
body,
})
const signed = await signer.sign(req)
await fetch(`https://${HOST}/event`, { method: 'POST', headers: signed.headers, body })
}
That events.map(JSON.stringify) catches people. The events array is an array of strings, not objects, and the error you get back does not say “stringify your events”.
The thing that really shapes the code, though:
💡 A Kinesis batch of 500 CDC records is not one publish. The channel is a top-level field of the request and you can send five events, so 500 records is at minimum 100 round trips, and realistically more, because records for different orders go to different channels.
Which forces the order of operations: group by channel, collapse, chunk into fives, then run with bounded concurrency.
The collapse step is free. If a batch holds four updates to the same row, the subscriber only needs the last one, and you already have the key and ts_ns because you’re grouping anyway:
function decode(rec: KinesisRecord): CdcRecord {
const raw = Buffer.from(rec.kinesis.data, 'base64').toString()
const cdc = JSON.parse(raw)
// ts_ns is ~1.7e18, well past Number.MAX_SAFE_INTEGER, so JSON.parse has
// already rounded it. Recover it from the raw text and keep it a string.
cdc.source.ts_ns = raw.match(/"ts_ns"\s*:\s*(\d+)/)![1]
return cdc
}
const byChannel = new Map<string, Map<string, CdcRecord>>()
for (const rec of event.Records) {
const cdc = decode(rec)
if (cdc.source.table !== 'draft_picks') continue
// a delete carries only `before`, so never reach straight into `after`
const row = cdc.after ?? cdc.before
const channel = `/drafts/${row.draft_id}`
const key = `${row.draft_id}:${row.pick_number}`
const seen = byChannel.get(channel) ?? new Map()
const prev = seen.get(key)
if (!prev || BigInt(cdc.source.ts_ns) > BigInt(prev.source.ts_ns)) {
seen.set(key, cdc)
}
byChannel.set(channel, seen)
}
That’s write-set compaction applied a second time, one layer further out.
Both of those are in the record format docs rather than anywhere you would look while writing the code. The rounding one is the nastier: at 1.7e18 the gaps between representable doubles are about 256 ns wide, so two distinct commits can compare equal and your newer record loses. Carry ts_ns as a string from the moment it arrives to the moment the browser compares it, including through whatever JSON you send over the channel.
On concurrency: too high and you hit the 10,000/s inbound quota and get throttled partway through a batch, which leaves you holding a batch that is half published. Too low and the function times out on a large batch. This is the moment to reach for the stream’s batching window instead of a bigger batch size, so you get more frequent, smaller batches rather than occasional enormous ones.
The consumer also owns failure. Turn on ReportBatchItemFailures so a single poison record doesn’t park its shard behind a batch that retries forever, set a maximum retry count, and give the event source mapping somewhere to put what it gives up on. Then notice what retries mean: a replayed batch republishes events that already reached subscribers, so your publisher is at-least-once for the same reason the stream is. The browser’s watermark absorbs that without being asked.
EventBridge Pipes into an API destination
No Lambda to own, an optional enrichment step, retries and a dead letter queue included. The catch is that API destinations post one event per invocation, so you get none of the batching or collapsing above and you pay per invocation. Good for low volume, high value changes. Bad for a chatty table.
The inverse: persist and broadcast in one hop
An onPublish handler with a DynamoDB data source writes to the table and broadcasts in a single call. No stream, no consumer Lambda, no CDC:
import { util } from '@aws-appsync/utils'
import * as ddb from '@aws-appsync/utils/dynamodb'
export const onPublish = {
request(ctx) {
const channel = ctx.info.channel.path
const createdAt = util.time.nowISO8601()
return ddb.batchPut({
tables: {
'run-steps': ctx.events.map(({ payload }) => ({
channel, id: util.autoKsuid(), createdAt, ...payload,
})),
},
})
},
response: (ctx) => ctx.events,
}
This is the closest thing to Supabase’s trigger, and it’s right for the agent-run and chat shaped features above, where the thing producing events and the thing persisting them are the same code.
It’s wrong for everything else, for one reason. It only sees changes that arrive through the publish path. A batch job, a second service, another team’s Lambda, an admin running SQL against the database at two in the morning: none of them publish, so none of them reach a subscriber. The entire value of CDC is that it sits below the application where nothing can route around it.
So it comes down to one question. Is your app the only writer, and will it stay that way? If yes, this is dramatically less machinery. If you can’t promise it, you want the stream, and the Lambda above is the price of not having to.
Wiring it into Next.js
The client, and where it lives
import { Amplify } from 'aws-amplify'
import { events } from 'aws-amplify/data'
Amplify.configure({
API: {
Events: {
endpoint: 'https://xxxx.appsync-api.eu-central-1.amazonaws.com/event',
region: 'eu-central-1',
defaultAuthMode: 'userPool',
},
},
})
const channel = await events.connect(`/drafts/${draftId}`)
const sub = channel.subscribe({
next: (data) => apply(data),
error: (err) => console.error(err),
})
// on teardown
sub.unsubscribe()
channel.close()
Subscribing has to happen in a client component, because a server component can’t hold a socket. That’s fine, but it means the initial data and the live updates arrive through different mechanisms, and joining them up is the next section.
Put the connection in a layout or a provider, never in a page. App Router preserves layout state across client-side navigation and throws away page state, so a channel opened in a page reconnects on every navigation, and connection requests are quota’d at 2,000/s per API. The protocol multiplexes up to 200 subscriptions over a single connection, so put the client somewhere that survives navigation and stop paying for the churn. While you’re there: React StrictMode mounts effects twice in development, so a cleanup that doesn’t actually call close() leaks a connection per navigation, and you’ll find that in a quota rather than in a test.
The last thing is that the apiKey in every getting-started snippet is not your production config. The browser should hold a user credential. On Cognito that’s userPool and you’re done. If you’re not on Cognito, and most Next.js apps and essentially every Supabase refugee aren’t, use an AWS_LAMBDA authorizer that validates the JWT you already issue, whether that’s Auth.js, Clerk or your own. I’m spelling this out because “do I have to adopt Cognito for this?” is the question that stops people, and the answer is no.
Applying an event: router.refresh() or client state
I haven’t seen this written up well anywhere, and it’s the decision that matters most.
The Supabase habit is to merge payload.new into useState. It’s familiar and there’s no round trip. But you now have two pieces of code producing the same view model, the server one and the merge one, and they drift. The merge path also has no idea about your authorization, so it’s only as safe as the payload you chose to send.
The alternative is to treat the event as a pure invalidation signal and call router.refresh(). The server component refetches through code that already exists, with authorization that already exists. One data path. It costs a round trip per event and re-renders the route segment, so coalesce with a trailing debounce of about 100ms and don’t point it at a high-frequency stream.
💡 If you’re going to
router.refresh()anyway, the payload only needs to be{ id, ts_ns, op }. The Next.js-native option and the “send a notification, not the row” rule turn out to be the same decision, and that one payload can’t leak a column you forgot to check.
My rule of thumb: cursors and typing indicators go to client state, because they are the payload and there’s no server-side view of them. Anything that’s a row in your database gets router.refresh().
The mount race
The obvious order is render, fetch, subscribe. That drops every change landing between the fetch and the subscribe, and it will be rare enough in development to survive review.
Correct order:
- Subscribe first, buffering events without applying them.
- Fetch the snapshot, which carries its own watermark:
ts_ns, or a version column. - Drain the buffer, discarding anything at or below the watermark.
- Apply live from then on, still keeping the highest
ts_nsper key.
That’s the same last-writer-wins strategy the DSQL docs prescribe for a Kinesis consumer, relocated into the browser. Same rule at both ends of the pipe, because both ends are looking at the same unordered at-least-once stream. Deletes need the same care in both places: drop the key entirely and a late insert carrying an older ts_ns will resurrect the row, so keep a tombstone with its timestamp.
In Next.js the watermark crosses the RSC boundary as a prop. The server component fetches the snapshot and passes ts_ns into the client component that subscribes.
Reconnect is refetch. There’s no history and no since cursor, so a subscriber that was offline for thirty seconds missed thirty seconds, permanently. The reconnect handler runs the same four steps as mount, which is a better outcome than it sounds: one code path instead of two. Supabase won’t replay for you either, it just hides the seam better.
The self-echo problem
A server action writes to the database. CDC fires. The event comes back to the very tab that made the change, which has already applied revalidatePath and possibly an optimistic update. So the optimistic value gets replaced, then replaced again, and if the echo is slow it can clobber a newer local edit with an older server state.
The fix is a schema decision. Put a client-generated originId on the write, carry it as a column, and it comes back to you in the CDC record. Then the client ignores events it caused:
if (evt.originId === myOriginId) return
Two lines of client code and one column. It’s also the most concrete meaning I know for “design your schema for realtime”, which is otherwise the kind of advice that doesn’t survive contact with a migration file.
Where each piece runs
The publisher is not in the Next.js Lambda. Under OpenNext your Next.js server is one function. The stream consumer is a separate one with its own concurrency and its own timeout, and it fails on its own terms. Conflate them and a CDC burst throttles your page renders, which is a spectacular way to turn a database write spike into an outage.
The Next.js app does exactly two things: subscribes from the browser, and writes to the database in a server action. It never publishes.
flowchart LR Browser --> CF[CloudFront] CF --> Next[Next.js on Lambda] Next -- write --> DSQL[(Aurora DSQL)] DSQL -- CDC --> KDS[Kinesis] KDS --> Pub[Publisher Lambda] Pub -- "POST /event" --> AE[AppSync Events] AE -- WebSocket --> Browser
The write path and the notify path form a loop through the database instead of a straight line through your app.
Local development
There is no supabase start equivalent. Nothing runs AppSync Events on your laptop.
Two workable answers. Give each developer their own Event API. It’s pay per use and a developer generates approximately no events, so it costs about nothing. Or put a RealtimeClient interface in front of it with an in-memory implementation for tests and offline work, which you probably want anyway for the tests.
It’s the day to day regression versus Supabase you’d meet soonest, the first time someone tries to work on a train.
What it costs
Billing is per operation, and a lot of things count as one: every message published, every message broadcast, every handler invocation, and every WebSocket operation including connects, subscription requests and pings. Messages are metered per 5 KB delivered per subscriber, and at any real event rate that is the term that swamps the others. One change delivered to 5,000 people is 5,000 operations, not one, so the bill tracks fan-out and not your write rate.
The 5 KB block is the part that surprised me on the pricing page. Delivery is quantised, so a 2 KB payload and a 200 byte payload bill identically, and shrinking a row that was never going to cross 5 KB saves exactly nothing. Above the boundary it inverts and becomes a cliff. At 5,000 subscribers taking one change a second, a 4.9 KB payload runs about $13,000 a month and a 5.2 KB payload runs about $26,000, for 300 bytes.
So the argument for thin payloads is not really about bytes. The new reason to send { id, ts_ns, op } is that it keeps you clear of a boundary your largest row would otherwise cross. The leak and router.refresh() arguments from earlier come along free. If your rows sit comfortably under 5 KB, payload size is not your cost problem.
Your cost problem is how many subscribers a change reaches. Channel granularity is the lever, and it is a big one. Put everyone on one firehose and each subscriber pays for every change in the system, including changes to entities they have never once opened. Per-entity channels charge them for what they are actually watching. Subscriptions are cheap. Delivery is not.