hero

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.


The problem

On my very first day at Linear, I was assigned an issue: LIN-9748 Draft comment is too similar to a posted one

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:

Draft warning prompt
This prompt appeared whenever a user attempted to navigate away from a page with an unsaved comment.

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.


Implementation (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:

  1. Server-persisted drafts for project updates (and comments, which share a similar data model)
  2. The above would allow us to asynchronously generate project update drafts on the server and send to clients using the underlying Linear Sync Engine™️

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.

Slack message

Demoing to the team

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:

Draft page
Accepting commissions for my insane design/FE skills

Implementation (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:

  • doing things correctly, instead of just making it work (as is the case with hackweek projects)
  • refining the data model to be easily extendable to new draft types introduced in the future
  • implementing new designs (s/o Conor Muirhead for both design and frontend implementation)

Data model

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

Syncing drafts

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.


Porting existing drafts

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:

  1. Feature flag is enabled for a user
  2. User's local storage drafts are retrieved
  3. Drafts are concurrently ported to the server, deleting them from local storage once successfully ported

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:

  1. We needed to avoid notifying the user if a draft port failed
  2. We wanted to prevent users from undoing the creation of a ported draft

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.


Expanding scope

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:

Velocity chart

Release

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.


A brief demo of the drafts experience we shipped to users on August 8th

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.


Further work

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.


Acknowledgements

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.