To try out RedwoodJS Realtime, visit: RedwoodJS Realtime Demo.
Also, you can clone the Realtime Demo App repository and run yarn rw dev
.
Note: The following is legacy information. For up-to-date information, see: the RedwoodJS Docs
Important:
This is an experimental preview feature not currently in any release. It is targeted for v6. It also requires a serverful deployment – it it is not designed for serverless deployments to Vercel, Netlify or AWS Serverless, etc.
Note
These docs are a work in progress and subject to change. Final docs will be released at a later date.
It is intended to develop in the open and share the direction of the Redwood Roadmap under Full-stack Table Stakes: add “missing” self-hosted features.
RedwoodJS offers a real-time solution without having to create your own implementation.
Real-time allows an application to:
- respond to an event (e.g. AddProductToCart, NewUserNotification)
- respond to a data change (e.g. Post 123’s title updated)
and have the latest data reflected.
Overview
https://link.excalidraw.com/readonly/8lKtrm4kEfXSxJZV9Cbu
Use Cases
- Application Alerts and Messages
- User Notifications
- Live Charts
- Location updates
- Auction bid updates
- Messaging
Considerations
To ensure your application responds in real-time, it’s important to consider the following factors:
- How frequently do your users require information updates?
- Determine the value of “real-time” versus “near real-time” to your users. Do they need to know in less than 1-2 seconds, or is 10, 30, or 60 seconds acceptable for them to receive updates?
- Consider the criticality of the data update. Is it low, such as a change in shipment status, or higher, such as a change in stock price for an investment app?
- Consider the cost of maintaining connections and tracking updates across your user base. Is the infrastructure cost justifiable?
- If you don’t require “real” real-time, consider polling for data updates on a reasonable interval. According to Apollo, in most cases, your client should not use subscriptions to stay up to date with your backend. Instead, you should poll intermittently with queries or re-execute queries on demand when a user performs a relevant action, such as clicking a button.
- How are you deploying? Serverless or serverful?
- Real-time options depend on your deployment method.
- If you are using a serverless architecture, your application cannot maintain a stateful connection to your users’ applications. Therefore, it’s not easy to “push,” “publish,” or “stream” data updates to the web client.
- In this case, you may need to look for third-party solutions that manage the infrastructure to maintain such stateful connections to your web client, such as Supabase Realtime, SendBird, Pusher, or consider creating your own AWS SNS-based functionality.
Realtime with GraphQL
RedwoodJS offers a first-class developer experience for real-time updates with GraphQL.
In GraphQL, there are two options for real-time updates: live queries and subscriptions. Subscriptions are part of the GraphQL specification, whereas live queries are not.
Regardless of the implementation chosen, a stateful server and store are needed to track changes, invalidation, or who wants to be informed about the change.
Subscriptions
RedwoodJS has a first-class developer experience for GraphQL subscriptions.
Subscribe to Events
- Granular information on what data changed
- Why has the data changed?
- Spec compliant
Example
- I subscribed to "NewBid”
- "NewBid”
- A bid was added to Auction 1
- A “NewBid” event to Auction 1 is published
- I find out
Live Queries
RedwoodJS has made it super easy to add live queries to your GraphQL server! You can push new data to your clients automatically once the data selected by a GraphQL operation becomes stale by annotating your query operation with the @live
directive.
The invalidation mechanism is based on GraphQL ID fields and schema coordinates. Once a query operation has been invalidated, the query is re-executed, and the result is pushed to the client.
Listen for Data Changes
- I’m not interested in what exactly changed it.
- Just give me the data.
- This is not part of the GraphQL specification.
- There can be multiple root fields.
Example
- I listen for changes to Auction 1.
- A bid was placed on Auction 1.
- The information for Auction 1 is no longer valid.
- I am notified.
Technology Notes
We live in a connected world where real-time needs are ever-growing, especially when it comes to the internet, the world’s most connected environment. In the realm of real-time, two main players come to mind: WebSockets and Server-Sent Events.
Choosing Server-Sent Events (abbr. SSE) for your next real-time driven project might sound appealing for several reasons, from simplicity to acceptance. However, you’ll soon discover that SSE has a limitation on the maximum number of open connections when used with HTTP/1 powered servers.
When used over HTTP/2, the maximum number of simultaneous HTTP streams is negotiated between the server and the client (defaults to 100). However, when not using HTTP/2, SSE suffers from a limitation on the maximum number of open connections. This can be especially painful when opening multiple tabs, as the limit is per browser and set to a very low number (6). Unfortunately, this issue has been marked as “Won’t fix” in both Chrome and Firefox. The limit is per browser + domain, meaning that you can open 6 SSE connections across all tabs to www.example1.com
, and another 6 SSE connections to www.example2.com
. (source: Stackoverflow)
SSE vs WebSocket
Advantages of SSE over WebSockets
- Transported over simple HTTP instead of a custom protocol
- Built in support for re-connection and event-id Simpler protocol
- No trouble with corporate firewalls doing packet inspection
Advantages of WebSockets over SSE
- Real time, two directional communication.
- Lower latency
“Stores”
- In Memory
- Persisted / Redis
RedwoodJS Project Setup
Setup Server
yarn rw exp setup-server-file
Setup Realtime
- Note: this may be a setup command that will do some of the following
- Add
realtime
lib- yarn rw setup realtime
- yarn rw setup realtime —redis
- yarn rw setup realtime —type=subs, lq — redis
// api/src/lib/realtime.ts
import { createPubSub, InMemoryLiveQueryStore } from '@redwoodjs/graphql-server'
export const liveQueryStore = new InMemoryLiveQueryStore()
// Note: may look like this if we setup with some out of the box subscriptions
// and maybe subscription generator will add to types?
export type PubSubChannels = {
newMessage: [payload: { from: string; body: string }]
}
// note: may be untyped or the above PubSubChannels is just empty
export const pubSub = createPubSub<PubSubChannels>()
- Add a
api/src/subscriptions/
directory - Configure Server GraphQL
// api/src/server.ts
import { pubSub, liveQueryStore } from './lib/realtime'
// ...
await fastify.register(redwoodFastifyGraphQLServer, {
loggerConfig: {
logger: logger,
options: { query: true, data: true, level: 'trace' },
},
graphiQLEndpoint: '/.redwood/functions/graphql',
sdls,
services,
directives,
allowIntrospection: true,
allowGraphiQL: true,
realtime: {
subscriptions: { subscriptions, pubSub },
liveQueries: { liveQueryStore },
},
})
- note
graphiQLEndpoint
setting may change; may need one for playground and one for the server endpoint - web toml Graphqlapi url → not functions
- may want to rename since the “i” is hard to see
How to Implement a Subscription
Note: we may implement a yarn rw exp generate-subscription
command.
Countdown Example
- There are no external SDL schema types or service resolvers in this example; all type a resolvers are self-contained in this single Subscription implementation file.
- The
schema
defines thecountdown
subscription type. - Upon subscribing, the countdown will begin
// api/src/subscriptions/countdown/countdown.ts
import gql from 'graphql-tag'
export const schema = gql`
type Subscription {
countdown: Boolean!
}
`
const resolvers = {
countdown: {
async *subscribe(_, { from, interval }) {
for (let i = from; i >= 0; i--) {
await new Promise((resolve) => setTimeout(resolve, interval ?? 1000))
yield { countdown: true }
}
},
},
}
export default resolvers
New Message Example
- Here we have the SDL to define the
Message
type, aquery
to fwtch the message fro a given room, and theinput
type andmutation
tosend
aMessage
to aroomId
.
// api/src/graphql/rooms.sdl.ts
export const schema = gql`
type Message {
from: String
body: String
}
type Query {
room(id: ID!): [Message!]! @skipAuth
}
input SendMessageInput {
roomId: ID!
from: String!
body: String!
}
type Mutation {
send(input: SendMessageInput!): Message! @skipAuth
}
`
- The services here return the id for a
room
andsend
publishes aMessage
on the thepubSub
channel. Note thatpubSub
is available on thecontext
on the service.
// api/src/services/rooms/rooms.ts
export const room = ({ id }) => [id]
export const send = async (
{ input }: { input: { roomId: string; from: string; to: string } },
{ context }: { context: { pubSub } }
) => {
const { roomId, ...newMessage } = input
context.pubSub.publish(`NewMessage:${roomId}`, newMessage)
return input
}
- The
subscription
defines theschema
and itsresolver
. - One may subscribe to the
newMessage
for aroomId
which gets handles in thesubscribe
generated function for thenewMessage
resolver that subscribes to theroomId
. - When the
message
is published by thesend
mutation, thenewMessage
resolve function returns the payload, i.e. the message’sfrom
andbody
values.
/// api/src/subscriptions/newMessage/newMessage.ts
import gql from 'graphql-tag'
import { logger } from 'src/lib/logger'
export const schema = gql`
type Subscription {
newMessage(roomId: ID!): Message!
}
`
const resolvers = {
newMessage: {
subscribe: (_, { roomId }, { pubSub }) => {
logger.debug({ roomId }, 'newMessage subscription')
return pubSub.subscribe(`NewMessage:${roomId}`)
},
resolve: (payload) => {
logger.debug({ payload }, 'newMessage subscription resolve')
return payload
},
},
}
export default resolvers
- When you mutate to
send
a message to a room, theNewMessage
event is published with the message. It is sent to those who have subscribed tonewMessage
events for that room. You can subscribe to these events using theuseSubscription
hook.
// web/src/pages/RoomPage/RoomPage.tsx
import { useSubscription } from '@redwoodjs/web'
const NEW_MESSAGE_SUBSCRIPTION = gql`
subscription ListenForNewMessages($id: ID!) {
newMessage(roomId: $id) {
body
from
}
}
`
function RenderNewMessage({ newMessage }) {
return (
newMessage && (
<div>
<h4>New Message: {newMessage.body}</h4>
<h5>From: {newMessage.from}</h5>
</div>
)
)
}
function ListenForNewMessages({ id }) {
const { data, loading } = useSubscription(NEW_MESSAGE_SUBSCRIPTION, {
variables: { id },
})
return <h4>New Message: {!loading && RenderNewMessage(data)}</h4>
}
const RoomPage = ({ id }) => {
return (
<>
<h1>Room {id}</h1>
<ListenForNewMessages id={id} />
</>
)
}
export default RoomPage
How To Implement a Live Query
- One implements a
LiveQuery
as one wuld any GraphQL query with the added step toinvalidate
the key when the data changes. - Here we have the SDL that defines the
Auction
andBid
types and the operations toquery
an auction with its bids and themutation
tobid
on the auction. - When implementing your schema be sure to use uuid and ID resource identifiers
Note: Considering
- add feature to realtime plugin to auto invalidate on mutations
- add option to cell generator to add
@live
to operation in cell generator
// api/src/graphql/auctions.sdl.ts
export const schema = gql`
type Query {
auction(id: ID!): Auction @skipAuth
}
type Auction {
id: ID!
title: String!
highestBid: Bid
bids: [Bid!]!
}
type Bid {
amount: Int!
}
type Mutation {
bid(input: BidInput!): Bid @skipAuth
}
input BidInput {
auctionId: ID!
amount: Int!
}
`
- The
auctions
service implements- a collection of auction data
- a resolver to fetch the data for an
auction
byid
- a way to
bid
on anauction
which willinvalidate
the auction by its key in theliveQueryStore
obtained from thecontext
- a resolver on
Auction
to determine itshighestBid
- When a
bid
mutation is made to an auction, all cell queries listening to that auction via the@live
directive will get the updated auction, bid and highest bid data. - A
InMemoryLiveQueryStore
instance tracks, invalidates and re-executes registered live query operations. - The store will keep track of all root query field coordinates (e.g.
Query.todos
) and global resource identifiers (e.g.Todo:1
). The store can than be notified to re-execute live query operations that select a given root query field or resource identifier by calling theinvalidate
method with the corresponding values. - A resource identifier is composed out of the typename and the actual resolved id value separated by a colon, but can be customized. For ensuring that the store keeps track of all your query resources you should always select the
id
field on your object types. The store will only keep track of fields with the nameid
and the typeID!
(GraphQLNonNull(GraphQLID)
).
// api/src/services/auctions/auctions.ts
import { logger } from 'src/lib/logger'
const auctions = [
{ id: '1', title: 'Digital-only PS5', bids: [{ amount: 100 }] },
]
export const auction = async ({ id }) => {
const foundAuction = auctions.find((a) => a.id === id)
logger.debug({ id, auction: foundAuction }, 'auction')
return foundAuction
}
export const bid = async ({ input }, { context }) => {
const { auctionId, amount } = input
const index = auctions.findIndex((a) => a.id === auctionId)
const bid = { amount }
auctions[index].bids.push(bid)
logger.debug({ auctionId, bid }, 'Added bid to auction')
const key = `Auction:${auctionId}`
context.liveQueryStore.invalidate(key)
logger.debug({ key }, 'Invalidated auction key in liveQueryStore')
return bid
}
export const Auction = {
highestBid: (obj, { root }) => {
const [max] = root.bids.sort((a, b) => b.amount - a.amount)
logger.debug({ obj, root }, 'highestBid')
return max
},
}
- When querying the Auction, you can use a standard RedwoodJS Cell that makes a
query
to fetch theauction
by anid
. - To make this a “live query”, use the
@live
query directive. - Whenever the
Auction
queried data changes (i.e, when invalidated in thebid
mutation) the query will refresh and return any updated data to yourSuccess
component.
// auction cell
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
export const QUERY = gql`
query LiveAuctionQuery($id: ID!) @live {
auction(id: $id) {
bids {
amount
}
id
title
highestBid {
amount
}
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Empty</div>
export const Failure = ({ error }: CellFailureProps) => (
<div style={{ color: 'red' }}>Error: {JSON.stringify(error)}</div>
)
export const Success = ({ auction }: CellSuccessProps) => {
return <div>{JSON.stringify(auction)}</div>
}
How It Works
-
useRedwoodRealtime
- It is automatically added to GraphQL Yoga plugins if realtime is configured.
import { context } from '@redwoodjs/graphql-server’
- It places any stores, such as the
liveQueryStore
, andpubSub
transports for managing subscriptions and invalidation into the context so that they can be used in services or anywhere that the RedwoodJS GraphQL context is available.
- It is automatically added to GraphQL Yoga plugins if realtime is configured.
-
Simple GraphQL Server Configuration
realtime: { subscriptions: { subscriptions, pubSub }, liveQueries: { liveQueryStore }, },
-
The RedwoodJS GraphQL Server now creates a Subscription for each subscription file defined in
api/src/subscriptions
, based on itsschema
declaration andresolvers
, when it assembles the schema from sdl types, services, and directives. -
This feature automatically adds the
@live
query directive to the executable schema and generated schema for the web side. -
SSE and text/event-stream
More Info
For more information on RedwoodJS’s real-time solutions, there are several resources available.
- Laurin Quast: Subscriptions and Live Queries - Real Time with GraphQL
- Subscription on GraphQL Yoga https://the-guild.dev/graphql/yoga-server/docs/features/subscriptions
- Live Query
- GraphQL over Server-Sent Events Protocol