Streaming sync operations
Our client SDKs rely on a connection to the backend PowerSync Service in order to sync changes from the upstream backend database to the local SQLite database. The syncing is achieved by the client following a set of commands from the server.
Sync commands typically relate to:
- Checking diffs between the server and local SQLite.
- Updating sync buckets with bucket items.
- Validation of sync checkpoints.
Initial client SDKs used HTTP streaming to create a long-lived connection which streams commands from the server to client in real-time.
Problems with HTTP streaming
We aim to provide support for PowerSync to a wide range of frameworks and development environments, including Flutter, React Native, web, Kotlin and Swift.
We noticed some pain points for using HTTP streaming when developing our React Native SDK.
React Native supplies an implementation of the common JavaScript [.inline-code-snippet]fetch[.inline-code-snippet] method. This implementation unfortunately does not support streaming request responses. If a default React Native [.inline-code-snippet]fetch[.inline-code-snippet] implementation tried to connect to our PowerSync Service instance, then sync commands would not be accessible to the client as they are emitted from the server. The client would buffer the commands — only yielding a set of commands once the HTTP request resolved completely. Typically the connection would only resolve when the token used to create it expires —which is typically in the order of magnitude of hours. This is very far from real-time syncing.
Luckily the React Native community has encountered this issue before and created a community implementation of [.inline-code-snippet]fetch[.inline-code-snippet] which does support HTTP streaming. Given the right setup conditions, this allows for real-time syncing between the React Native client and the server.
Unfortunately, the setup conditions for the [.inline-code-snippet]fetch[.inline-code-snippet] method are quite cumbersome. Multiple polyfills are required, and in some cases code changes to native platform project files are required. React Native projects are no strangers to having to use JavaScript polyfills. These can be readily applied by using the polyfills package which the community fetch implementation is included in. However, the network inspector and Flipper debug tools (if using Expo < 51) need to be disabled (in the native [.inline-code-snippet]android[.inline-code-snippet] project folder) if developing on the Android platform. This requirement leads to lengthy and rather complicated installation steps for our React Native SDK.
WebSockets are supported in React Native
React Native also has WebSocket support available. WebSockets typically also provide a long-lived connection where messages can be bi-directionally sent between a server and client in real-time.
This connection method does not directly require any JavaScript polyfills or native project changes to provide real-time streaming. This makes it an appealing connection method for React Native clients.
Differences between HTTP streaming and WebSockets
Given the above, it might seem that WebSockets are a simple drop-in replacement for HTTP streams. This is unfortunately not the case.
WebSockets provide event-driven communication while HTTP streams provide streaming communication. The devil is in the details when comparing the connection methods.
Back-pressure:
One key difference is the handling of back-pressure. The server will send commands to the client as commands are needed, but the server needs to not overload the client with a large queue of unprocessed commands. At the same time, the client should not cause a large backlog of buffered events on the server side.
HTTP streams utilize protocol-level flow control mechanisms ([.inline-code-snippet]WINDOW_UPDATE[.inline-code-snippet] frames) to manage each stream independently. Additionally, the [.inline-code-snippet]Stream[.inline-code-snippet] and [.inline-code-snippet]AsyncIterator[.inline-code-snippet] classes, when combined, can provide built-in back-pressure support.
The chain of the server yielding sync commands via an [.inline-code-snippet]AsyncGenerator[.inline-code-snippet] to a [.inline-code-snippet]ReadableStream[.inline-code-snippet], which is sent over HTTP(S) to the client, which consumes a [.inline-code-snippet]ReadableStream[.inline-code-snippet] to [.inline-code-snippet]AsyncIterator[.inline-code-snippet], which asynchronously processes the sync commands (e.g. saving data to SQLite), is automatically kept in constant balance.
WebSockets are transmitted over TCP which also offers a protocol level of back-pressure management, but additional application logic is required to provide the same level of support as HTTP streams in conjunction with [.inline-code-snippet]AsyncIterator[.inline-code-snippet]s. This is mostly due to the server not knowing when the client has finished processing the yielded commands.
The bi-directional nature of a WebSocket does allow for feedback from the client to mitigate the effects of back-pressure. This has to be implemented on the application layer. As we aim to provide support to multiple programming languages and frameworks, any solution needs to be supported on a wide range of languages.
RSocket
Implementing a cross language communications protocol with back-pressure support over WebSockets is not a minor task. Finding an off-the-shelf solution which has wide support and is highly customizable and configurable would be ideal. When searching for an existing solution, the RSocket library ticked all the criteria boxes:
- Its reactive streams provide back-pressure support by default.
- It’s an open-source project.
- A wide range of programming languages and pluggable network transport layers are supported:
- It’s a well-designed solution with a well thought-out specification. See the protocol for more information.
Reactive streams work on a client credit basis. See the docs for more information. Essentially, the client requests N amount of commands from the server. The client is free to take its time to process these commands as they arrive. The server will only yield and respond with more commands once the client has requested them — which is typically triggered once the client has finished processing a subset of instructions
Implementing RSocket in Server and Client
Our backend PowerSync Service is a TypeScript project, with source now available (see here). We made use of the [.inline-code-snippet]rsocket-core[.inline-code-snippet] and [.inline-code-snippet]rsocket-websocket-server[.inline-code-snippet] packages to implement the routing for our [.inline-code-snippet]/sync/stream[.inline-code-snippet] endpoint.
Due to React Native containing the most pain points with HTTP streaming, we added socket support to our JavaScript/TypeScript SDKs using the [.inline-code-snippet]rsocket-core[.inline-code-snippet] and [.inline-code-snippet]rsocket-websocket-client[.inline-code-snippet] packages.
We currently default to using HTTP streaming in our React Native client SDK, and allow developers to switch to WebSockets using a configuration parameter. See our docs for details.
Next steps
We started with React Native due to the reasons mentioned above, but the next step for us is to implement WebSockets in our other client SDKs.
Thoughts or questions? Get in touch
If you have any questions or feedback, join us on Discord.