This post details the work behind a feature called Drafts, which began as an internal hackweek project and recently shipped.
I've been at Linear for just over a year, and it’s been an incredible experience. Working alongside such a talented team of engineers & designers, and contributing to a product that our users truly love has been deeply rewarding.
One of the best parts of working at Linear is the opportunity to dogfood our own product. Experiencing the same challenges as our users gives us valuable insights, which directly influenced a project I had the opportunity to lead from start to finish: Linear's Persisted Drafts.
In this post, I’ll walk you through how this feature came to be.
This issue highlighted a common problem: Users often start entering information in Linear but then abandon the process midway, leading to unsubmitted issues, comments, and updates.
While Linear did persist drafts in local storage, they wouldn't sync across devices and were lost if the user cleared their cache. Most crucially, there was no reliable way to surface outstanding drafts to users due to the nature of client-side storage, which prevents us from enforcing strict structure and integrity or ensuring that foreign references are intact.
We decided that a temporary solution for the core issue would be to add a warning prompt:
While this warning helped users avoid losing their work, it was a stopgap solution at best and also ️didn't meet Linear's standards for product quality – we could do better.
v0.1
)In May 2024, Linear held an internal hackweek, my first, before our offsite to Mexico City. Here is what Karri Saarinen, our CEO, had to say about it:
Hackweeks [are] about scratching your own itch. Trying something you always wanted to try. While you can build something that works, these [should be] concepts and ideas, not something real we plan to ship.
A few days before the hackweek started, I pitched my idea to the team: Autogenerated project update drafts.
Project updates are a key part of Linear, used to convey progress on projects to stakeholders and team members. Today, users have to write project updates manually. But Linear already has an abundance of data on the progress of projects, so there's an opportunity to auto-generate a draft as a starting point. At scale, this saves a lot of time for our users!
This project interested me because it was a perfect opportunity to experiment with GenAI, and it also promised to be quite an impactful feature.
I proposed my idea to the team as a two-part project:
Asynchronous generative AI is powerful because it feels like Linear is working on your behalf, and it's a great way to avoid the inevitable latency of LLM calls (and the associated loading spinners).
The Linear team was excited about it, but more so about the underlying feature that would make it possible: drafts.
For the fun of it, I decided to continue working on both parts of the project – the AI and drafts components.
Four days later, I had a working prototype of the feature (only locally, of course 😃). I demoed it to the team, and here's what they had to say:
Yeah ship it immediately
— Nan Yu, Head of Product
🤌
— Karri Saarinen, CEO
Here's a screenshot of what the (drafts) feature looked like back then:
v1
)After receiving feedback on my hackweek prototype, it was clear that a better drafts experience was something we needed to ship. Jori and our head of product, Nan, agreed that this was a high-impact feature that we should prioritize and slotted it into our roadmap.
I spent the next couple weeks:
Here's a simplified version of the table schema we designed for drafts, which allows for easy extension to new draft types.
The idea was to have a single table for all draft types, with foreign keys to the parent entity (e.g., issue, project, project update, comment)
and a jsonb
column for any additional data associated with the draft.
CREATE TABLE "public"."draft" ( -- Columns ----------------------------------------------- "id" uuid NOT NULL DEFAULT uuid_generate_v4(), -- bodyData is used for the ProseMirror content "bodyData" jsonb NOT NULL, -- data is used for any properties associated with the draft "data" jsonb NOT NULL DEFAULT '{}'::jsonb, -- The user who created the draft "userId" uuid NOT NULL, -- The issue the draft is associated with (if any) "issueId" uuid, -- The project the draft is associated with (if any) "projectId" uuid, -- The project update the draft is associated with (if any) "projectUpdateId" uuid, -- The parent comment the draft is associated with (if any) "parentCommentId" uuid, ... -- Constraints -------------------------------------------- -- Ensure that each draft has exactly one parent entity CONSTRAINT "IDX_..." CHECK ((num_nonnulls("projectId", "parentCommentId", "issueId", "projectUpdateId") = 1)) -- Foreign key constraints PRIMARY KEY ("id") ); -- This index prevents duplicate drafts from being created CREATE UNIQUE INDEX "IDX_UNIQUE_DRAFTS" ON public.draft USING btree ("userId", "issueId", "projectId", "projectUpdateId", "parentCommentId") NULLS NOT DISTINCT; -- Indices on the foreign keys to support lookup by parent entity
If you use Linear, you know it operates in realtime. To maintain this standard, it was essential that drafts created on one device were synced across all of a user's devices. Since drafts are user-specific, sync packets (via Linear's Sync Engine) were only sent to a user's clients when a draft was created, updated, or deleted.
Another significant part of the project was to surface outstanding drafts made before this feature was introduced, which were stored in local storage. To do this, we needed to port these drafts reliably to the server.
The porting process was as follows:
A key challenge was ensuring the porting process was executed serially to avoid concurrent readers of local storage from porting the same drafts multiple times.
For instance, if a user had multiple tabs open, it was crucial that drafts were ported one at a time across all tabs.
To accomplish this, we leveraged WebLock
, which is supported by all major browsers:
const WebLock = { async request(lockName, callback) { if (navigator.locks) { return await navigator.locks.request(lockName, callback); } else { // Fallback behavior if Web Locks API is not supported return await callback(); } } }; async () => { // Use WebLock to ensure mutual exclusion on local storage processing across all tabs await WebLock.request("portLocalDrafts", portLocalDrafts); }
At Linear, we leverage local transactions to uniformly handle mutation failures and enable undoing actions (through Cmd+Z) throughout the app. This approach simplifies the developer's task by offloading the responsibility of implementing these functionalities for each individual feature.
However, since the draft "porting" process was done in the background rather than initiated by the user, that introduced a couple of constraints:
For instance, a port might fail if a user had a draft attached to the same parent entity on two different devices — in such cases, the first insertion wins.
To address these constraints, we adjusted the logic within local transactions to accommodate these specific exceptions when required.
As the project progressed, its scope expanded to improve the issue drafting experience and include sub-issue drafts, which also needed to be ported. Realizing the additional workload was too much to handle alone, I asked the product team for help. Jacob Shumway then joined the project and was a huge help in bringing it to completion faster than I could have managed on my own.
For the Linear aficionados, here's what the velocity chart for the project looked like:
When we released the feature to beta customers on July 23rd, we immediately gained valuable feedback from them.
For example, we discovered that some users had over 100 drafts ported, which was surprising and highlighted the need for better draft management tools. We also learned that some users were using drafts as a way to save comments for later, which was not the intended use case.
After incorporating feedback and fixing more bugs, we released the feature to all users on August 8th, 2024.
Customers have been loving the feature, and we've seen a significant increase in drafts created since its release. Here's what one user had to say:
I saw this today, I was so excited that I sent a big hooray message to the company, thanks so much for this new feature!!
I'm very proud of the work we've done on drafts, evolving them from a hackweek project into a first-class feature throughout Linear. It’s been an amazing experience contributing to such a prominent, user-facing feature that solves a significant pain point for our users.
Linear is in the process of building a mobile app, and drafts will play a key role in the handoff experience between devices. With drafts, users will be able to seamlessly continue their work across devices, accommodating users who use Linear on the go.
Additionally, as we expand Linear's feature set, we will introduce new draft types where they make sense, and we're excited to see how users will leverage them in their workflows.
I want to thank Tom and Nan for letting me run with this project and providing invaluable guidance along the way. Shout out to Jacob, Conor, and the entire Linear team for their feedback and assistance in bringing this feature to fruition.