# Improving Asana’s Pageload Performance

> Discover how Asana optimizes page load speeds for its heavy React single-page app, delivering a seamless user experience across millions of daily pageloads.

Source: https://asana.com/inside-asana/improving-asanas-pageload-performance

## Improving Asana’s Pageload Performance

Asana serves 2.5 million pageloads a day, peaking at 3.5 million. Each one is a user's first impression of the product — the moment between intent and action — and for a tool teams rely on all day, a slow one compounds quickly: lost focus, dropped sessions, a product that feels sluggish overall.

Under the hood, improving that experience isn’t simple. Asana is a large single-page application – you page load once, and every navigation and interaction after that stays within the same browser session, progressively loading data and resources as needed. It’s also built completely on React, making it a JS-heavy application. Altogether, this results in an application loading process that’s a lot more complex than simply downloading and rendering some HTML.

**Asana’s pageload progresses in three main stages:**
- **Download, parse, and render initial HTML.** The browser makes a single round trip to Asana’s page load server to receive an HTML file. It then parses this file, and renders the resulting HTML, which is the splash screen (Asana logo + loading indicator). The act of parsing script and link tags causes the browser to fire off requests for those additional resources in the background, and to initialize the Websocket connection to our reactive data loading server ([LunaDb](https://asana.com/resources/scaling-lunadb)).
- **Await download and processing of HTML resources.** Before the Asana app can be meaningfully rendered, we await the download and processing of a few critical resources that were requested via script / link tags. These all happen in parallel, so the slowest one ends up defining the critical path.
- **Initial render + data requests.** Having the critical HTML resources, we kick off the initial render. This is driven entirely by JS since we rely on client-side React for rendering. The act of performing this initial render causes the JS to possibly request more JS dynamically, and also send requests over a websocket to receive data from [LunaDb](https://asana.com/resources/scaling-lunadb). Once all these have returned, this stage is over, and page load is considered complete.

_Chrome dev tools timeline showing the three stages described above_

We knew some customers were experiencing very slow page loads (&amp;gt;15s), and even the average experience (5.75s, 5% [winsorized mean](https://en.wikipedia.org/wiki/Winsorized_mean)) left a lot to be desired. So, we set out to optimize how Asana loads — starting, as you do, by decomposing pageload into the stages where time was actually being spent.

## **Opportunity #1: Multi-Region &amp; Optimistic Routing at the Edge**

Stage 1 is a hard serial barrier in front of every other optimization — until the initial HTML arrives, the browser can't start downloading JS, CSS, or opening WebSockets. 

One possibility to optimize the process is to move initial HTML generation out of one US data center and into dozens of AWS regions via CloudFront Lambda@Edge. But there was a catch — the Pageload Server wasn't just rendering a template. It was authenticating cookies against user shards, looking up the user's last domain, and deciding which release to serve based on the user's traffic group. None of that data lives at the edge.

So we asked the question that unlocked the project: _does this all have to be correct, or just usually correct?_

Three data points said "usually" was good enough:
- 86% of users belong to a single domain; 98% to two or fewer.
- ~40% of pageloads carry the domain ID in the URL (new-style URLs include it as the first path segment).
- The remaining ~58% can be served from a cookie hint.

We shipped a new route_hint cookie storing the user's last domain and promotional stage, updated on every pageload. The edge inspects cookies, makes its best guess (_optimistic routing_), and returns the initial HTML in tens of milliseconds. The browser starts downloading bundles immediately, no longer blocked on a cross-continent round trip.

Real validation gets deferred to the next request the client makes anyway — start_session to LunaServer, which already has the data it needs. If the optimism was wrong (auth expired, routing hint stale, user banned), start_session returns a graceful error telling the client to update its cookies and redirect or reload.

In practice, fewer than 0.5% of pageloads needed correction. The other 99% saw initial HTML latency drop from hundreds or thousands of milliseconds to tens of milliseconds. The result was a dramatically faster initial HTML response, especially in satellite regions.

**Cluster**

**P50 improvement**

**P95 improvement**

prod-jp1 (Tokyo)

− 905 ms

− 2,046 ms

prod-au1 (Sydney)

− 1,036 ms

− 1,688 ms

prod-eu1 (Frankfurt)

− 375 ms

− 576 ms

prod (US)

− 147 ms

− 247 ms

## **Opportunity #2: Service Worker Bundle Caching**

The browser cache is supposed to make this trivial — hit pageload twice, the second one is fast. The catch was our deploy cadence: we ship to prod roughly every three hours, and every deploy invalidates the cached bundle. The result was that only ~50% of pageloads had the main JS bundle cached, despite Asana being a heavily-used product. Cached pageloads were 1.5–2.5 seconds faster on average.

Stated differently: we were constantly invalidating our own cache and had no way to refill it before the user came back.

A Service Worker gave us that control. The webapp polls a lightweight release endpoint every few minutes to ask "is there a newer release?" When the answer is yes, it messages the Service Worker to fetch and cache the new bundle _in the background_, before the user navigates anywhere. The next pageload finds a warm cache.

A few details mattered for getting this right:
- **Race cache against network.** A cold Service Worker has activation overhead; in the rare case where cache lookup is slower than the network, we let the network win.
- **Coordinate across tabs.** Polling per-tab would multiply load on the release endpoint. Tabs share state via localStorage so only one is actually polling at any given time.
- **Aggressively prune stale assets.** The main bundle is ~7MB; without active eviction, browser storage limits become a real risk.

This strategy boosted our bundle cache hit rate from 52% to roughly 70+%, shaving crucial hundreds of milliseconds off both download and evaluation times.

## **Opportunity #3: Persistent, Cross-Tab Data Cache**

Stage 3 was almost always blocked on data fetching from LunaDb — and that data was usually data the user had already loaded. Open inbox at 9am, refresh at 10am: same projects, same teams, mostly the same notifications. We were paying full WebSocket round-trips for it anyway.

We already had an in-memory stale data cache that worked well within a tab. The problem: _it died with the tab_.

The fix was to back the cache with IndexedDB, the browser's persistent client-side database. Now data fetched in one tab could be reused by another, or by a fresh pageload hours later. Every WebSocket update from [LunaDb](https://asana.com/resources/scaling-lunadb) now writes through to IndexedDB; on pageload, we load IndexedDB's contents back into the in-memory store before the app starts rendering.

A natural question: _why hydrate the whole store, instead of just what the current page needs?_ The in-memory store is denormalized — a Task is just a Task, not "the Task that request X wanted." If two different requests both need the same Task, they share a single object. Our IndexedDB schema mirrored that, which meant we had no mapping from requests to syncables. With nothing to query against, the safest move was to load everything.

A few constraints shaped the design:
- **Encryption.** IndexedDB persists user work content to disk, so we encrypt every row using a server-supplied key derived from the auth ticket and a per-domain-user secret.
- **Bounded persistence.** WGO (Work Graph Object) grows unbounded and users may belong to multiple domains - clear on logout; namespace by domain user ID so we don't mix data across accounts on shared machines.
- **Cross-release compatibility.** Data schemas evolve between deploys. The MVP discarded IndexedDB contents whenever a new app version was encountered — safe, but it limited cache lifetime to a few hours.

Initial impact: roughly a **35-point lift in cache hit rate** on data loaded during the initial render, up from zero.

### **When the cache competed with itself**

Then we tried to extend cache lifetime across releases, and hit something we didn't expect - “_When the cache competed with itself_”. As the IndexedDB volume grew, downloading and decrypting all of it at pageload became slow enough that the time saved by cache hits was getting eaten by the cost of populating the in-memory store. Hydration cost scaled linearly with cache size; cache hits didn't.

The fix was a schema redesign. Instead of just storing every syncable and figuring out at boot which ones were relevant, we added a requests table mapping each subscription request to the exact syncable IDs it depends on. When a page needs a subscription's data, we look up just those syncables and decrypt only what's needed.

The result: hydrate on demand. The cache can grow large without slowing pageload, because we only pay the cost for data we're about to use. With this, **30–40% of previously-uncached loads became near-instant** - a delightfully snappy Pageload.

## **Lessons**

A few things stand out in hindsight.

**Decompose before optimizing.** The three-stage breakdown wasn't fancy, but it gave every project a clear hypothesis about what it would and wouldn't move. Without it, we'd have spent months on optimizations that didn't touch the critical path.

**Think in probabilities, not in guarantees**. The biggest wins came from accepting that Pageload didn't have to be right on the first try, as long as the system could detect and recover from being rarely wrong. The lambda's optimistic approach was a major architectural shift that depended entirely on this trade-off.

**Compound metrics are hard to move.** Our north-star metric was overall pageload completion time, but we attacked it by improving sub-stages. Predicting how a sub-stage improvement would translate to the compound metric required careful modeling of which stages sat on the critical path under which conditions.

**Lambdas need tuning.** Even after we got the EdgeRouter lambda working correctly, hitting target latency required iterating on memory allocation and CPU configuration. Edge compute has its own performance characteristics that don't show up until you're running production traffic.

We're not done. Globalization work continues, and there's headroom in client-side caching, bundle optimization, and reducing initial render work. But the pageload Asana users see today—particularly outside the US—is meaningfully faster, smoother repeat visits, and the architecture is a much better foundation to build on.

#### _About the authors_
- [Vishrut Shah](https://www.linkedin.com/in/vishrutnshah/)_(Senior Engineering Manager) and_[Will Hastings](https://www.linkedin.com/in/willhastings4/)_(Staff Technical Lead) are both members of the Product Infrastructure group, dedicated to making Asana fast and functional at scale for customers._
- [Bjorn Swift](https://www.linkedin.com/in/bjornswift/)_,_[Justin Chuchill](https://www.linkedin.com/in/sirchill/)_, and_[Vincent Siao](https://www.linkedin.com/in/vsiao/)_are the staff technical leaders working at the intersection of product, frameworks, and infrastructure, setting the group’s technical vision in service of making Asana a high quality product for our customers._

#### _Team Shout Outs_

_Improving Asana’s Pageload performance and architecture has been a huge team effort, involving everyone on the Product Frameworks team: Leo Tang, Sean Sun, Andrew Manzanero, Barry McNamara, Ankur Kumar, Arlan Jaska, Serg Opushnyev, Ulrich Geilmann and the Infrastructure team: Magni Steinn Thorbjornsson, Asta Lara Magnusdottir, and others._

- [How Asana Built A Resilient ID Allocation System](/inside-asana/how-asana-built-resilient-id-allocation)

Engineering

Every object in Asana—every task, project, comment, and attachment—needs a unique identifier. At Asana, these IDs are sequentially incrementing integers, allocated in blocks from ...

- [The Athletic Position](/inside-asana/the-athletic-position)

Engineering

I. Before the Ball Leaves their StringsThere is a moment from the 1988 French Open final that most people who talk about tennis haven’t had to think about carefully.Steffi Graf pl ...

- [How Asana leverages AWS Inspector for total visibility over infrastructure vulnerabilities](/inside-asana/asana-leverages-aws-inspector-visibility-infrastructure-vulnerabilities)

Engineering

#### Software Engineer

Scanning for vulnerabilities across multiple AWS accounts, eliminating noise, and turning vulnerability findings into actionable work is a challenging but important undertaking. A ...

- [How Asana built a custom LLM evaluation framework for AI Teammates](/inside-asana/custom-llm-evaluation-ai-teammates)

Engineering

Artificial Intelligence (AI)

For teams building with large language models, model selection shapes nearly every dimension of the product experience: quality, latency, cost, and tone. The industry is moving qu ...

- [Improving Asana’s Pageload Performance](/inside-asana/improving-asanas-pageload-performance)

Engineering

Asana serves 2.5 million pageloads a day, peaking at 3.5 million. Each one is a user's first impression of the product — the moment between intent and action — and for a tool team ...

- [Engineering](/inside-asana/engineering-spotlight)
