Elements vs Pusher
Pusher is a hosted realtime messaging company, part of MessageBird (now Bird) since 2020. Its main product, Pusher Channels, sends messages from your server to browsers and mobile apps over WebSockets: your server publishes an event to a named channel through Pusher's API, and every client subscribed to that channel receives it. Its revenue is subscriptions: each monthly Channels plan is a flat price with a daily message quota and a cap on concurrent connections (Pusher pricing).
Elements is not a messaging company. It is an integrated app environment for building web apps, built for people and their agents: a coherent system that includes a project server that runs while you work, a default app framework, a package installer, a test runner, a bundled Postgres database, and deploy. Realtime is part of the framework: LiveTable keeps database rows in sync in every browser showing them, and channels send any other message from the server to the browsers listening. Both run inside your app, and Postgres, which your app already uses, carries the messages between servers. Elements charges for that tooling, per machine, and meters nothing (pricing).
The difference is that realtime in Elements is code in your app, so the build checks it along with everything else. When you or your agent change a field, a query or a template, the build reports what broke in the realtime code, from a renamed field to a failing test, in milliseconds. With Pusher, realtime is a second service. Your app saves the data, publishes it to Pusher as a separate step and answers Pusher's authorization requests, and Pusher counts every delivery against your plan. In Elements, one write saves the row and updates every browser watching it.
At a Glance
| Elements | Pusher | |
|---|---|---|
| What it is | An integrated app environment with realtime built into its default framework | A hosted realtime messaging service |
| What you pay for | Tooling, per machine, per month (pricing) | A flat monthly plan with a daily message quota and a connection cap (pricing) |
| What counts against it | Nothing | Every publish, every delivery to each subscriber, and every webhook (message count) |
| Where your messages go | Your servers and your Postgres | Through Pusher's servers |
| Saving a change and telling browsers | One write does both | Save to your database, then publish to Pusher |
| The page's first load | Rendered on the server with the current rows | Fetched by your own code before subscribing; Pusher stores no message history |
| Showing a change at once in the sender's browser | Built in | You write that code |
| Checking the code | The build checks it from server to template | Event names and JSON agreed by convention, unchecked |
| Who may listen | Checked in the route that opens the page | An authorization endpoint you write for Pusher to call |
| Several app servers | Messages pass between them through one shared Postgres | Pusher's servers hold the connections |
Realtime Without a Second Service
Realtime is what puts one person's change on everyone else's screen: chat, live dashboards, shared lists, presence. A framework without it sends you to a hosted service like Pusher, with its own account, keys, client library and bill. Your users' messages then pass through someone else's servers.
In Elements, realtime ships in @elements/app and runs inside your app, on your servers. Every app server listens on Postgres with LISTEN and NOTIFY, so a message sent from one server reaches browsers connected to any of them (channel). There is no second service to sign up for, no API keys to issue and no client library to install. Your messages never leave your infrastructure. Realtime deploys with the rest of the app, in the same elements deploy (deploy).
Pusher Counts Every Delivery
Pusher's plans are sized by messages per day, and a message is counted every time it moves. One event published to a channel with 50 subscribers counts as 51 messages: one for the publish and one for each delivery. REST API calls, webhook calls and the join and leave events of presence channels count too (message count). The count grows with your audience, so the more popular a room is, the faster it spends the quota. Connections beyond the plan's limit fail (plan limits).
In Elements, a message to ten browsers or ten thousand costs what your server costs. Messages, connections and bandwidth never appear on an Elements bill (pricing).
One Write Saves the Row and Tells Every Browser
A realtime feature has two jobs: store the change, and tell everyone who is watching. With Pusher they are separate steps in separate systems. Your server writes to your database, then calls Pusher to publish the event. Private and presence channels add a third job: an endpoint on your server, /pusher/auth by default, that Pusher's client library calls before every subscription, and that must decide whether this user may listen (Pusher authorization).
In Pusher, a private chat room is three pieces of code in two places. After saving the message, your server calls pusher.trigger() with the channel name, an event name and the message. Your server also answers the authorization endpoint, signing each subscription once it has checked the user. In the browser, the page calls pusher.subscribe() and binds a handler that adds each event to the list. Pusher's JavaScript quick start and private channels guide show each piece.
In Elements, a LiveTable write is one call. It runs your handler, saves the row, and broadcasts it to every browser watching that table or that slice of it. Authorization is ordinary code in the route that opens the page and in the table's own handlers, where the session is already available (partitions, handlers). Here is a chat room in three files, with its sign-in check:
app/shared/services/chat.tsElementsimport { LiveTable, session } from "@elements/app"; export interface Message { id: string; roomId: string; author: string; body: string; createdAt: Date; } export let messages: LiveTable<Message> = new LiveTable<Message>({ insert: (item) => { session.isLoggedInOrThrow(); return messages.insert({ ...item, author: session.getOrThrow("userName") }); }, });
app/pages/room/index.tsElementsimport { Request, Response, session } from "@elements/app"; import { messages } from "#app/shared/services/chat"; import html from "./template"; export default function route(req: Request, res: Response) { session.isLoggedInOrThrow(); return new html({ messages: messages.view({ roomId: req.params.id }) }); }
app/pages/room/template.ehtmlElementsimport { session, LiveView } from "@elements/app"; import type { Message } from "#app/shared/services/chat"; function send(messages: LiveView<Message>, draft: { body: string }) { messages.insert( { body: draft.body, author: session.get("userName")!, createdAt: new Date() }, () => draft.body = "", ); } <html (messages: LiveView<Message>, private draft: { body: string } = { body: "" })> <ul> <li e:for={m of messages.sort((a, b) => +a.createdAt - +b.createdAt)}> <strong>{m.author}</strong> {m.body} </li> </ul> <form onsubmit={() => send(messages, draft)}> <input value={draft.body}> <button>Send</button> </form> </html>
The route checks the session and opens the room's messages. The handler takes the author's name from the session, so no one can post as someone else. Every browser in the room sees a new message as soon as it is saved, and a browser in another room never receives it, because each room is its own partition with its own broadcast. A view opened by a signed-in user also stops when that user signs out, so nothing arrives after the session ends (livetable).
The database can send to browsers too. A Postgres trigger that calls pg_notify reaches every listening browser, so a row written by a job, a script or psql appears on open pages with no app code publishing it (channel, live from SQL).
The Page Arrives With Its Data
A realtime page needs two things: the current data, and every change after it. Pusher delivers only the changes. It keeps no message history; a cache channel remembers the last event, for up to 30 minutes (cache channels). So the page loads its data some other way, then subscribes, and your code has to merge the two without losing or duplicating a message that arrived in between.
An Elements page renders on the server with the rows already in it, so the first paint has data, for people and for search engines. view() starts listening before it runs the query, and the browser uses each row's id to drop a message it already has, so a change made while the page loads is never lost and never shown twice (options). For a long table, a window keeps one page of rows live and loads older ones on request (windows).
Optimistic Updates Built In
A change should appear on the screen of the person who made it without waiting for the network. Pusher only delivers messages, so this is your code: add the row to the page, send it to your server, and match the server's echo back to it.
A LiveTable does this for you. The browser gives the new row its id before sending it, shows it at once, and matches the server's broadcast to it when it arrives. If the server refuses the write, the row is taken back out (mutations).
The Build Checks Realtime Code
An agent building a live feature gets the most from correction, hearing what it got wrong the moment it gets it wrong. Pusher hands it a layer of integrations instead: a client library in the browser, a server library to publish, and an authorization endpoint between them, each with an API to look up and a seam the agent has to keep in agreement by hand. A library is a shortcut only for code an agent cannot write quickly from standards, and pushing a saved row to the browsers watching it is basic plumbing an app framework should already have.
A Pusher event is a name and a JSON payload. The server that publishes it and the browser that binds to it agree on both by convention, and nothing checks that they still agree after either side changes.
In Elements, a LiveTable and a channel are declared with a TypeScript type, LiveTable<Message> or Channel<Alert>, and that type follows the rows from the server into the template. Rename a field and the build reports every template that still reads the old one.
Elements ships that plumbing, LiveTable and channels, and puts the rest of its engineering into the check: realtime code goes through the same type checking, tests and program analysis as every other line, because the framework and its build are one coherent system. The project server runs that build. It runs while you work and holds the build graph and build state in memory, so on every save the build state is updated almost instantly. It builds in milliseconds and answers agents and humans in microseconds, through elements build -json (build).
Tests reach realtime code too. A test makes the same LiveTable write the browser makes, through your handler, and it broadcasts the same way, with nothing to mock (mutations, tests).
Presence Is a Table and Two Events
Presence shows who is on a page right now. In Elements, a channel listener reports when a browser connects and when it leaves: write a row on one and delete it on the other. The list lives in your database, where you can query it. Each row carries the user's id and name from the session, and no join or leave is ever counted (presence). The same events drive typing indicators and status dots (typing indicator).
Moving From Pusher
An agent can port a Pusher app easily, because the realtime layer moves into the app. A change to a row reaches every browser from the write itself, through a LiveTable, and any other event goes over a channel. You keep every live feature your users see, and you gain realtime that runs on your own servers and is checked by the same build as the rest of the app, with no message quota.
Questions
What is the difference between Elements and Pusher?
Pusher is a hosted service that delivers messages from your server to browsers, sold as flat monthly plans with a daily message quota and a cap on concurrent connections. Elements is an integrated app environment whose default framework includes realtime. LiveTable keeps database rows in sync in every browser, and channels send any other message. Both run inside your app and pass messages through the Postgres it already uses, and nothing is metered.
Is Elements a Pusher alternative?
Yes. LiveTable and channels replace Pusher Channels. They run inside your app on your own servers, so there is no second account, no API keys and no message quota, and nothing is metered.
How does Pusher count messages?
Pusher counts every publish and every delivery. One event published to a channel with 50 subscribers counts as 51 messages, and API calls, webhooks and presence joins and leaves count too (Pusher docs). Elements counts nothing: it is priced per machine.
Does Elements realtime work across several servers?
Yes. Every Elements app server listens on Postgres with LISTEN and NOTIFY, so a message sent from one server reaches browsers connected to any of them. The servers share one Postgres database, such as a managed one.
How do I move from Pusher to Elements?
An agent can port a Pusher app easily, because the realtime layer moves into the app. A change to a row reaches every browser from the write itself, through a LiveTable, and any other event goes over an Elements channel. You keep every live feature your users see, and you gain realtime on your own servers with no message quota.