惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
月光博客
月光博客
aimingoo的专栏
aimingoo的专栏
Google DeepMind News
Google DeepMind News
Recent Announcements
Recent Announcements
MyScale Blog
MyScale Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
SegmentFault 最新的问题
Hugging Face - Blog
Hugging Face - Blog
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
F
Fortinet All Blogs
小众软件
小众软件
D
Docker
U
Unit 42
博客园 - 聂微东
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
爱范儿
爱范儿
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
IT之家
IT之家
云风的 BLOG
云风的 BLOG
博客园 - 司徒正美
有赞技术团队
有赞技术团队
腾讯CDC

Linear Blog

Styling Linear for the future with StyleX Sharing Linear’s growth with the people building it How we built Linear Agent Introducing Loops - Linear Teaching an agent to auto-fix bugs - Linear Now Linear writes the code, too - Linear Reviewing code in the agent era - Linear Code review should be fast - Linear Code Intelligence for Linear Agent - Linear How we hire at Linear - Linear Output isn’t design - Linear How we use Linear Agent at Linear Post mortem on Linear security incident on March 24th, 2026 A calmer interface for a product in motion Design is more than code - Linear How our Customer Experience team works in Linear - Linear Continuous planning in Linear - Linear Designing remote work at Linear - Linear Self-driving SaaS: When software runs itself - Linear A Linear spin on Liquid Glass - Linear Best practices for designing Linear Dashboards - Linear Why we committed to a zero-bugs policy - Linear How Commure uses Dashboards to track performance and guide planning - Linear How we built Triage Intelligence - Linear Giving our team liquidity through Linear’s first tender offer - Linear How Cursor integrated with Linear for Agents - Linear Quality Wednesdays: How we trained our team to see what doesn’t work - Linear Our approach to building the Agent Interaction SDK - Linear Inside Mercury’s six-month journey building with AI agents - Linear Building our way: Announcing our Series C - Linear
Rebuilding Linear’s delta sync read path
2026-08-18 · via Linear Blog

Linear is a local-first application. Each client maintains a local database so that creating an issue, changing its status, or navigating a workspace doesn’t require a network round trip. That makes the app feel immediate, but the tradeoff is that a client returning online needs a way to catch up, fast.

Rather than download the entire workspace again, the client sends a checkpoint containing the ID of the last change it applied. Delta sync uses that checkpoint to retrieve only what has changed in the meantime.

Flow diagram showing a client database checkpoint at 420 and a workspace log feeding into a delta sync, which identifies relevant actions 421–500 and updates the client database checkpoint to 500.

Flow diagram showing a client database checkpoint at 420 and a workspace log feeding into a delta sync, which identifies relevant actions 421–500 and updates the client database checkpoint to 500.

Some of our largest workspaces produce close to one million sync actions per day, and a client that’s been offline for only a few hours can return hundreds of thousands of sync actions behind. Those results must also be filtered by what the user can access and has subscribed to. Across more than 20 TB of sync actions, delta sync becomes a large, permission-aware set intersection that is increasingly difficult to serve. We built a new read path with turbopuffer to keep that query fast and predictable, even as our biggest workspaces continue to grow.

What a delta sync query actually does

In practice, every change that Linear clients are concerned with creates a sync action. Each sync action has an ordered ID, the affected model, the type of change, routing metadata, and the data a client must apply.

These sync actions form an application-level log that clients replay into their local databases. It operates at a level of abstraction higher than the Postgres write-ahead log and describes changes in terms the client understands, such as updating an issue, deleting a comment, or archiving a project. Each workspace has its own immutable ordered sync action log where new sync actions are appended.

Linear filters the log by what the user can access and has subscribed to before returning the relevant sync actions.

A simplified delta-sync query looks like this:

Flow diagram showing four action filters intersecting to produce an ordered list of action IDs for a client.

Flow diagram showing four action filters intersecting to produce an ordered list of action IDs for a client.

For context, sync groups encode access to parts of a workspace, and sync subscriptions narrow that further to the models and views the client currently needs.

Why the Postgres read path stopped scaling

Our previous delta sync serving path used a second Postgres table designed for these reads. As workspaces began producing thousands of sync actions between client checkpoints, each request combined widening ID range with array-overlap predicates for access and subscriptions, and Postgres spent a growing amount of CPU testing and discarding irrelevant rows.

This caused four problems:

  • Tail latency became increasingly volatile, even when median latency remained healthy.
  • Adding read replicas did not reduce the amount of intersection work required per request.
  • Database maintenance and replica lag could delay how quickly a returning client caught up.
  • Extending the query with additional filtering dimensions made an already CPU-intensive path even more expensive.

We could continue tuning indexes and adding replicas, but neither would change the fundamental shape of the query. We needed a serving index designed to combine very large sets with low latency and cost that remained predictable for our largest workspaces. While we investigated using a Postgres GIN index, the cost at write for our scale didn’t make sense.

Changing the shape of the query

turbopuffer is designed around inverted indexes that can efficiently evaluate large filter intersections. For each filterable attribute, it maintains a mapping from an attribute value to the sorted IDs of documents that contain it. That sorted set of IDs is called a posting list.

For delta sync, each document represents the metadata for a sync action, and its document ID is the sync action ID itself. A sync group, therefore, has a posting list containing every sync action routed to that group. A sync subscription has another, containing every sync action relevant to that subscription.

104108121107121129104108129121126104107108121129104108121126129108 … 130108121129

When a client requests a delta, ContainsAny unions the posting lists for the user’s sync groups. It does the same for the client’s sync subscriptions. turbopuffer then intersects those sets with the requested sync action ID range and any remaining filters.

In the Postgres path, the database examined candidate rows from a potentially large range and repeatedly tested them against the request’s permission and subscription arrays. With inverted indexes, those filters are already represented as sorted sets of matching sync action IDs. The query can combine those sets directly and narrow in on the small intersection the client needs.

The surrounding workload also aligns well with turbopuffer’s architecture:

  • Sync actions are immutable, so the index is dominated by appends rather than updates.
  • Each workspace maps to its own turbopuffer namespace, keeping tenant data and query work isolated.
  • Only fields used for filtering are indexed, while large sync action payloads remain in Postgres.
  • turbopuffer’s object-storage architecture allows the index to grow without requiring the full dataset to remain in memory.

Putting turbopuffer in the read path

We built a custom change-data-capture pipeline that reads committed sync actions from a Postgres publication and writes their metadata to turbopuffer. From the moment a sync action commits in Postgres to the moment it becomes available in turbopuffer, replication latency is roughly one second at p50 and a few seconds at p95.

On the read side, we re-designed sync as a two-stage pipeline that includes a metadata scan followed by late enrichment.

The metadata scan returns only the ordered IDs and routing fields needed to decide whether a sync action belongs in the response. While the actions are still represented by this lightweight metadata, the server applies access checks, subscription filters, packet transformations, and deduplication. Only after that filtering is complete do we fetch the full payloads. The server batches the surviving sync action IDs, retrieves their data from Postgres, and streams the enriched sync actions to the client.

System architecture diagram showing an application write flowing through a Postgres transaction, logical replication, Turbopuffer metadata indexing, filtering and de-duplication, payload enrichment, and an ordered delta returned to the client.

System architecture diagram showing an application write flowing through a Postgres transaction, logical replication, Turbopuffer metadata indexing, filtering and de-duplication, payload enrichment, and an ordered delta returned to the client.

Late enrichment is an important part of the design. In the old path, Postgres could read and decode large JSON payloads for candidate sync actions that were later rejected by an access or subscription check. The new path carries compact metadata through most of the server process and enriches only the sync actions that are about to leave for the client.

This keeps the expensive part of the pipeline focused on the sync actions the client will actually receive. As a result, we reduce Postgres I/O, JSON processing, and memory use while preserving an ordered streaming response.

Handling replication lag safely

Moving reads to a secondary index raises the question of what happens to sync actions that have been committed in Postgres but are not yet visible in turbopuffer.

We do not assume replication is perfectly current. For every delta request, Postgres serves a small, authoritative slice at the head of the sync action log, while turbopuffer serves the larger historical range behind it. The two ranges intentionally overlap, and the server deduplicates the combined result by sync action ID. If turbopuffer is unavailable or cannot cover the requested range, the request falls back to Postgres.

This makes the serving path tolerant of replication delay, out-of-order index visibility, deploys, and restarts. The replicator uses durable progress tracking and idempotent writes, while the read path treats Postgres as the final authority for the most recent actions.

Before sending production traffic through the new path, we ran it in shadow mode. Each request was executed against both turbopuffer and the existing Postgres path, and we compared the resulting sync action IDs. Delta sync is a correctness-critical path; a faster query is only useful if it returns exactly the sync actions the client is supposed to receive.

Predictable latency at scale

The clearest difference between the two read paths showed up in tail latency as workspaces grew. On the Postgres path, larger candidate ranges and access filters steadily increased p95 and p99 latency. With turbopuffer’s posting-list indexes, however, both remained largely flat as workspace size increased.

Chart comparing tail latency from small to enterprise workspaces, showing Postgres P95 and P99 latency increasing with workspace size while Turbopuffer P95 and P99 remain low and nearly flat.

Chart comparing tail latency from small to enterprise workspaces, showing Postgres P95 and P99 latency increasing with workspace size while Turbopuffer P95 and P99 remain low and nearly flat.

In production, that flatter tail made catch-up much more predictable.

This matters most for our largest customers. A workspace generating close to one million sync actions per day should not make reconnecting progressively slower as it grows, nor should large permission and subscription sets force clients into a full bootstrap.

The broader lesson for us was that storing a change log and serving a change log are different problems. Postgres is the right place to commit and retain our client-facing WAL. The reconnect query, however, is an ID range intersected with very large permission and subscription sets.

Representing those sets as posting lists changed the economics of that query. turbopuffer gave us a serving path whose latency stays predictable as both the log and the customer grow, while Postgres remains the authoritative source of the underlying data.