Pixelpusher: Real-time peer-to-peer collaboration with React
We have been conditioned to accept certain truths in software development. We accept that our software won’t work in an airplane, on the subway, or in an elevator. We accept that our apps will only be released subject to Apple’s approval, if we want them to fit in a pocket. We accept that we will only be able to read our writing if Google Docs is online, or if we respect GitHub’s terms of service. If our application finds an audience, we’ll spend the rest of our days on call for it, paying for cloud hosting services, and trying to keep our audience’s data safe and secure.
We have been conditioned to accept these notions because they came along with some really great perks. We insist on instant and ubiquitous collaboration features. We demand that all of our data is available everywhere all of the time. We trust our users are always running the latest version of our software, and get upgraded with every refresh.
Something has been lost.
What if we could go back and reconsider some of these trade-offs? What if our applications and data could live on our devices, but without sacrificing the collaboration we’ve come to expect, and without losing easy access to our data from all of our devices?
We’ve attempted to do just that, by taking an existing application and adding collaborative tools to it… without any infrastructure required. In essence, we’ve added “Google Docs” to a project without the “Google”.
Introducing Pixelpusher: An experimental collaborative pixel art editor
Pixelpusher is an experiment in local-first development. It’s a multi-user collaborative pixel art editor that is truly serverless. It can run entirely on the computers of the users who use it, and it never goes down because it doesn’t require any infrastructure of any kind to operate.
Under the hood, Pixelpusher is built using modern web technology (specifically React and Redux), and it looks and feels mostly like a modern web application. Indeed, Pixelpusher is based on the pixel-art-css project by Javier Valencia. We loved that it was a fun, creative, and self-contained React app with a clean architecture. By modifying an existing application we forced ourselves to work within the framework of the original author’s design and couldn’t simply build to suit our goals.
Pixelpusher differs from a traditional web application in two big ways.
Perhaps the most obvious difference is that it is locally installed software. Pixelpusher is built on Electron, a kind of hybrid of application runtime and web browser from GitHub. Electron lets you build desktop applications using both client-side and server-side web technologies. Being a locally installed application means that, although you do have to install it, you never have to worry about it being unavailable when you need it most. Further, building with Electron lets us do work that isn’t possible in the browser, such as storing files locally and opening certain kinds of network connections.
The second is that Pixelpusher is a local-first application. All the the data and the code it needs to work lives your local machine, and is shared peer-to-peer with other clients as needed. This means the API can’t go down, because there is no API. Even without access to the broader internet you can still collaborate with other people by directly transmitting data from one user’s device to another. We’ll talk more about how this works below, but put briefly, we build documents out of streams of changes rather than bytes and then distribute those over a peer-to-peer connection.
Once you get used to this architecture, the notion that we route all our data through Amazon’s us-east-1 data center just to move data across a room starts to look rather suspicious.
Collaboration in Pixelpusher
The Pixelpusher project is an experiment in collaboration technology, and combines features of different collaboration systems. The result is a hybrid of the real-time Google Docs model and the Git “merging branches” approach, streamlined for accessibility to non-technical users.
Let’s take a look at how this works.
First, to share your art with another Pixelpusher user, you simply send them the pxlpshr:// URL found above the drawing. When your friend opens up that URL, they’ll see a live window into your work right away, and you’ll see their name and avatar appear in the application.
From here, both of you can edit the drawing and see each other’s changes. Of course, if you both continue working while offline, you might create some conflicts to resolve when you finally reconvene. These conflicted pixels will appear highlighted with a red frame in the application. Both you and your friend will see one color or the other selected by default, but if you don’t like what Pixelpusher chose, you can always change it using our conflict resolution UI.
Later, you or a friend might decide to take the drawing in a different direction. Instead of scribbling something speculative all over your shared canvas, you could create a new “version” by clicking the “duplicate” button on the drawing’s thumbnail. Once you do so, you’re able to make whatever changes you like without interrupting what anyone else is doing. If and when you’re ready, it’s up to you to decide whether those changes are merged back into the original drawing, or thrown away, or simply left on the side as an alternative perspective.
Under the hood
Our goal in designing Pixelpusher’s collaboration features was to build an architecture that integrates comfortably into existing developer workflows and feels natural for React/Redux developers to adopt. Let’s follow a change from a user action through the system until it appears as a visible change on another client’s screen.
First, a user takes an action in Pixelpusher, like clicking on a pixel to set its color. That action creates a Redux action that travels through the reducer to update the state object. In your reducer, you’ll make some of those changes inside a special “change” block that records all the changes you make to that data structure. Because Pixelpusher also records the state of the document when you made those changes it’s able to put them back together cleanly on other clients.
To send those changes to your collaborators we append them to a log of actions, all chained together and signed by a private key. Using a peer-to-peer network protocol, described below, we send that log to any collaborator devices that are online. Several devices can independently append actions to the log. As all these changes from different sources arrive at each client, they are combined and sorted into a consistent order to produce a document, and any possible conflicts are detected and surfaced. This process always works the same, regardless of the order in which the messages arrive, or whether the clients were online or offline. Every client with the same set of operations will always see the same resulting document.
This is accomplished using a few different open source components. First, the change generation and reconstitution is performed by Automerge, a JSON-like CRDT (conflict-free replicated data-type). CRDTs are a special kind of data structure designed to support distributed systems, and they guarantee eventual consistency across unreliable networks.
The data sharing is built using two components borrowed from the dat project called hypercore and discovery-swarm. Hypercore implements the signed log of changes and a protocol for distributing them, and discovery-swarm is a brilliant hack that handles finding and connecting to peers using either mDNS/Bonjour for local connections, or by piggy-backing on the public Bittorrent DHT to find peers beyond your local network.
We’ve put these pieces together in a new library we call hypermerge, which includes a very simple example chat application as a demonstration.
But what about conflict resolution?
A common misconception of automated conflict resolution is that it leads to data loss. With Automerge, the complete history of the document is preserved, and past versions of the document can be restored at any moment. Contrast that with traditional conflict resolution, which tends to resolve conflicts at the last moment, such as when a client submits a new document version. At this point, a lot of the information about how things have come to be the way they are is already lost, and the system tends to simply choose a simple heuristic like “last write wins” across an entire document.
Automerge, on the other hand, records changes as they happen along with additional metadata to capture their intent, and so it only actually encounters conflicts when there is an ambiguous ordering of changes to the same field.
These conflicts can be ignored or surfaced in the app, depending on your preference as a developer. In practice, dealing with conflicts hasn’t been a major hurdle for the applications we’ve built. In Pixelpusher, merging work often results in conflicts: if two users think a pixel should have different colors, the situation is likely to require explicit resolution by a user, but until it is resolved, we can still pick one value or another to show in the application.
Finally, it’s worth noting that humans are pretty good at this stuff. For example, while it’s possible for a user to completely wipe a canvas and do something new, we have found that people have a natural instinct for conflict avoidance. When we know someone else might also be working on a document, we tend to be more cautious with our own changes.
Okay, this sounds neat. I want to give it a try!
You can try Pixelpusher for yourself (and you should, it’s a lot of fun), or you can check out the code from Github and take a look at how it all works. I would especially recommend taking a look at the example chat application in hypermerge to see how easy this can all be to use.
Whatever you do, we’d love to hear from you.
Pixelpusher is an Ink & Switch project built by Jeff Peterson, Jim Pick, and Peter van Hardenberg. It would not exist without Javier Valencia, who had no idea we spent the last two months working on a project derived from an app he wrote. Special thanks to Martin Kleppmann for his support of Automerge, and Mathias Buus (@mafintosh) for writing most of the peer-to-peer things we used.