Technical lessons from building a compiler startup for 3 years
Pagedraw recently shut down and is now open source. Pagedraw is a UI builder like Sketch + a compiler that translates your designs into React code. If you want to know more, take a look at https://pagedraw.io
Instead of doing a “It was an incredible journey, thank you so much everyone“ type of post, we think what would be the most helpful for other people interested in DevTools, programming, or startups in general is to just try to distill the most important lessons we learned doing Pagedraw over the past three years.
A lot of these lessons might only be applicable to building complicated, performance sensitive, UI heavy products. We don’t think any lesson here should be taken as an “always true” type thing. But we think they were certainly true in the context of Pagedraw.
The context is that Pagedraw was a very technically challenging product to build, and that was one of the most rewarding things about working on it. To give you a sense of some of the stuff we worked on:
- An in browser Sketch-like design tool w/ live collaboration a la Google docs
- A compiler that turns any design into pixel perfect React render functions
- A testing infrastructure that diffs screenshots of the generated code rendered in browser in order to verify that the compiler is correct
- A Turing complete “visual language” where you can create ifs, recursion, loops, functions, etc in order to develop UIs
- A LISP-ish interpreter for said visual language
- Cool perf optimizations like taking over parts of the React rendering scheduler in order to have more control over performance and achieve 60fps w/ thousands of divs on screen
- A system that allows you to bring arbitrary React code into the editor — similar to what FramerX also ended up doing — and a way to sandbox that code within multiple different iframes in order to prevent it from interfering w/ the rest of the editor.
- A JS serializing system that’s pretty flexible and allows for diffing and rebasing stuff very naturally
As we write this, Gabriel is now very happily working at Brex — a credit card for startups — along with several other ex-Pagedraw engineers. Jared is excited to start his work at Facebook next week. Still, feel free to reach out to us at gabriel@brex.com and jpochtar@gmail.com if you’re interested in any of the stuff here and we’re more than happy to chat about it. =)
Hopefully these lessons and our codebase can help someone in the future.
Gabriel Guimaraes and Jared Pochtar
1) Build something people want
This is not a technical lesson but is pretty important so we’re putting it here as well.
The main failure of Pagedraw was that we worked on really cool tech but failed to create a product that a lot of people want.
This is also the YC motto, and it is very true.
Sometimes, we kept working on it just because the tech was so cool and that is a big failure mode of startups that have really interesting technical problems but haven’t yet found strong product market fit.
We think realizing the vision of automating the boring parts of frontend development is still a very exciting possibility. But we feel like the ratio of amount of work invested versus ease of profitability or user acquisition was pretty bad in our case.
Plus the fact that we wanted to make a profit out of it led us to decisions that weren’t necessarily the best for the product overall. For example we chose to keep all of our user files and the compiler in the cloud so it doesn’t feel like “installed software” but sometimes that made the developer experience worse.
Also, with devtools in general — especially ones that have a profound impact on your dev flow like Pagedraw — customers often want to know that they can “fix” the tool if they need to instead of depending on some third party. A customer’s worst nightmare is to find that their development is blocked by an external tool for some unforeseen reason. Hence open source Pagedraw actually makes more sense in many ways.
2) Developers often think wrong about performance
We see a general trend towards premature performance optimization, with code written in certain ways for the sake of performance. At Pagedraw we had a simple system:
- Performance is never a justification for anything if you haven’t measured it
- First you write whatever code is the most readable and you completely ignore performance
- Go back to step 2 and make sure you completely ignore performance, because you probably haven’t
- Then you measure the performance
- Then you optimize
We found that this approach actually led to less code that was much more maintainable overall, because the first order optimization was always readability, which is one of the cornerstones of maintainability.
Plus we were able to optimize our performance multiple orders of magnitude and got to 60fps in reasonably sized docs by adding caches in the right places after the most readable code was written.
In general it’s a great idea to ask the question “if performance was not an issue, how would we do this?” And we think several great abstractions — including React itself — were built by people who asked that very question.
3) Functions are serious business and readability is king
In the same vein, another important part of our engineering culture at Pagedraw was that creating a function is a very serious endeavor. It creates an abstraction that other people can — and will — build upon.
In general, we thought that bad abstractions are often worse than no abstractions and so when we weren’t sure about a function’s signature we’d rather inline it everywhere — even if that creates a lot of duplicated or triplicated code.
Copy and pasting the same code many times before defining your functions forces you to think harder about which functions you’d like to create.
The other point is that readability is extremely important — if not the most important thing — when writing code. The Pagedraw codebase is pretty short overall (< 20k lines of code for the core product stuff) and that’s something we were very proud of, which only happened because of a huge focus on readability and on cutting lines + creating good abstractions.
4) Programming needs more “checks”
A very useful way of doing testing at Pagedraw was what we called “checks” for the lack of a better name. Instead of testing invariants about the code at each new PR, checks are basically comparing the code against itself at a previous commit.
The most important version of this was that, at each new commit, we compiled thousands of docs and checked that the generated code didn’t change between the new commit and what’s in master. If it did change (like when someone made changes to the compiler) we’d fall back to actually checking that the screenshot of the generated code didn’t change versus what’s in master.
If someone wants to introduce a bugfix that actually intends to change the generated UI, we had tooling that would show exactly which user docs would be affected and exactly what those diffs would be.
In general it’s a very useful idea to know what effects your code change has when compared with the code that is currently in master. We recently came across a blog post by Ted Kaminski about the same topic and we agree with him that it’s a shame we don’t have better tooling today to allow people to easily introduce this style of testing to their codebases.
5) Separate what’s UI state from what’s a cache of the backend
With React, people often put a bunch of stuff inside the this.state
variable or whatever state management library they’re using, without making an explicit separation between what’s the actual source of truth state that happens to live in the client from what’s a cache of the backend.
That makes apps very confusing to reason about because those very different things get conceptually jumbled together, when they should be treated quite separately.
6) More generally, separate what’s source of truth data from what’s computed/cached
The slightly more general version of the point above is that you should always have a mental model of the data dependency graph in any system. Most systems can be thought of as a “data waterfall” where there’s a dependency graph where some data is created from some other data and so forth. In that sense, all computed data is actually a cache and should be treated as such, requiring it to be properly invalidated, etc.
If you can separate all of that out and make it explicit in your code, that’s even better because it makes complex code easier to reason about.
7) Don’t trust standards
We didn’t follow many of the “Web dev best practices” that were widespread at the time. For example:
- We never called
this.setState
. Instead , we calledforceUpdate
all the time in our React app. - We just used global mutable state instead of the whole immutable Reduxy patterns people liked to enforce. It was simple and it worked great.
- Our render functions weren’t fully pure. They were actually idempotent.
- We banned fancy CSS rules and did everything w/ inline styles before that was cool
- We had no issue w/ using invisible spacer divs instead of the more “semantic” margins or paddings.
In some cases you actually might want to do some of the things above but make sure you have a real reason to do it instead of just “it’s the standard”. Jared is really into this and he’ll write more about it.
8) Live collab is the right collab
When working on frontend code that is fetching data from some REST API and rendering it to the screen, if you don’t update the data on the screen live as the data changes in the backend, your code actually gets more complex than if you do everything live updating all the time from day one.
The reason for this is that you run into decisions like: “whenever someone clicks a button to update something in the backend, should we return data related only to that button or should we return more data?” If you just return data related to what the button did, you run into the problem where your frontend has a global state that might actually never have existed in the backend, which is pretty bizarre.
If you build just a bit of tooling that fetches everything all the time when anything changes in the backend, things are actually simpler to reason about.
9) Whenever you want to “export” something, think about “rebasing” instead
One of our main problems at Pagedraw was how to export docs from Sketch into Pagedraw, because designers would often do their work in Sketch or Figma, and later wanted to bring their work into Pagedraw in order to turn it into production code.
The naive way to approach this is to simply create a fresh Pagedraw doc from an existing Sketch doc every time. Now what happens if someone makes a change to the original Sketch doc? They can’t just export again because they also probably already made changes to the Pagedraw doc as well.
Now there are hacky solutions like writing down which blocks in the Sketch doc were already exported and then just exporting the unexported ones. That’s a pretty bad approach in our opinion.
The problem here is that you want to have two sources of truth for the same conceptual thing — a design in this case — and you want to be able to edit both of them and have them reconcile.
The answer we found was Git. Essentially treating Sketch and Pagedraw as two separate branches in a git workflow gets you 90% of the way there. And then you implement an export function that translates A to B but you also need to have a rebase function that lets you rebase diffs of B on top of each other.
Since we built everything live collabed from scratch, we actually got the rebase function for free, since rebasing diffs is the basis of most live collab systems.
By the way this is also how React works at a high level. render
is an “export” function that translates application state into UI screens. The virtual DOM layer is a cache and React is a diffing algorithm that knows how to rebase changes in the UI screen world.