OWLS, or the Odoo Web Library, has always been considered reactive and component-driven from the get-go if you have ever had a chance to build anything on the Odoo UI. It was always quite decent out of the box. Nevertheless, solid defaults go only so far as soon as your application becomes more complicated, such as live dashboards, POS screens, or reports containing thousands of lines. With a reckless approach towards its development, you will very soon end up experiencing performance issues, which, by the way, cannot be considered a luxury in the realm of enterprise applications. This blog post focuses on those strategies that proved the most helpful.
What Actually Affects Performance in OWL?
Before diving into fixes, it's important to know where things can go wrong. OWL performance mainly depends on four areas:
- How your components are structured
- How do you manage the state
- How and when you fetch data from the backend
- How often does the UI need to update? Get any of these wrong, and you'll have a UI that re-renders constantly, overloads the backend with unnecessary requests, or simply feels slow to use.
Let's go through each one.
Common Performance Issues You'll Run Into
Most OWL performance problems trace back to a handful of recurring mistakes. Recognizing them early saves a lot of debugging time:
- Components re-rendering way more often than they need to
- Stuffing too much data into the reactive state
- Making too many RPC or API calls sometimes the same call multiple times
- Putting heavy computation directly inside templates
- Trying to render a huge list all at once
None of these is hard to fix once you know what to look for. The sections below cover each one with practical examples.
State Management
State in OWL is reactive in nature. Any alteration results in a re-rendering of the component. This is one side of the story, but on the other hand, this can prove to be the drawback of the application. If the data within the state object is not required in the UI, then rendering will happen unnecessarily. My thumb rule for state is only to store those items that must lead to an update in the UI.
Best Practices
- Keep state lean only store what the template actually needs
- Avoid nesting large objects or arrays inside the reactive state
- Update only the specific field that changed, not the whole object
Example
this.state = useState({
count: 0,
});This keeps the reactive surface area tiny. Updating count triggers a targeted re-render, nothing more.
Reducing Unnecessary Re-Renders
Excessive re-rendering is probably the most common OWL performance issue I see in the wild. The tricky part is that it's invisible you won't notice it just by looking at the screen, but the browser certainly does.
How to avoid it
- Don't update the state unless something actually changed
- Break large components into smaller, focused ones; a child component only re-renders when its own props change
- Never trigger state updates inside a loop; batch your changes or restructure the logic
Example
this.state.count++; // only this field updates -- minimal re-render
It's a small thing, but being deliberate about what you update and when adds up quickly in complex components.
Data Fetching — Timing Is Everything
How and when you access your data can make an enormous difference in how your application performs. If you make a call to the back end at the wrong time within the component's life cycle, you will end up performing extra renders.
Use willStart() for Initial Data Loads.
The willStart() method gets called prior to the rendering of the component for the very first time, thus making this the perfect place where to fetch data that your template requires. The component will not be rendered until this finishes, so you won’t see anything empty.
async willStart() {
this.data = await this.rpc("/api/data");
}Use onWillUpdateProps() for data that needs to refresh when props change, but leave willStart() as your go-to for the initial load.
Cutting Down on RPC Calls
Each RPC request is a round-trip to the server. Although you may have fast network connections, these can mount up; and, within Odoo’s framework, an element making five distinct search Read requests when loading is performing five times more than it needs to.
Best Practices
- Never make the same API call twice cache the result
- Use Promise.all() to run independent calls in parallel instead of one after another
- If multiple fields can come from one model, fetch them in a single call
Example: Running Calls in Parallel
// Slow -- calls run one after the other:
const customers = await this.orm.searchRead('res.partner', [], ['name']);
const invoices = await this.orm.searchRead('account.move', [], ['name', 'partner_id']);
// Fast -- both calls fire at the same time:
const [customers, invoices] = await Promise.all([
this.orm.searchRead('res.partner', [], ['name']),
this.orm.searchRead('account.move', [], ['name', 'partner_id'])
]);
Example: Caching a Response
async willStart() {
if (!this.cachedData) {
this.cachedData = await this.orm.searchRead(
'product.product', [], ['name', 'list_price']
);
}
this.state.products = this.cachedData;
}This pattern is especially useful for reference data currencies, units of measure, and product categories that rarely change during a session.
Handling Large Datasets Without Freezing the UI
It’s a surefire way to give your app a malfunctioning look when you drop a thousand items into a t-foreach. That’s because your browser will need to maintain a DOM element for each one of those elements, as will OWL.
Better Approaches
- Paginate – show 50 or 80 records at a time and let the user navigate
- Load in batches – fetch the first chunk immediately, then load more on demand
- Filter before rendering – only pass the records the user actually needs to see
Example
<t t-foreach="visibleItems" t-as="item" t-key="item.id">
<div><t t-esc="item.name"/></div>
</t>
Notice visibleItems, not the full items array. The filtering and pagination logic lives in JavaScript, so the template stays fast and simple.
Keep Logic Out of Templates
Templates run every time the component re-renders. If you're calling a function or doing any kind of computation inside a template expression, that work is repeated on every update even if the underlying data hasn't changed. It's a subtle performance drain that's easy to miss.
What to Avoid
<t t-esc="calculateValue(item)"/>
calculateValue() runs on every render. If it's doing anything non-trivial, that cost compounds quickly.
What to Do Instead
Pre-compute derived values in JavaScript ideally in willStart(), onWillUpdateProps(), or a dedicated getter and let the template just read the result:
this.processedItems = this.items.map(item => ({
...item,
displayValue: calculateValue(item),
}));The template becomes a straightforward read of processedItems.displayValue. Clean, fast, and easy to test separately.
Component Structure
The way you organize your components affects more than just maintenance; it can affect performance, too. One big component that is responsible for all of the components’ state or props means that anytime anything inside it is updated, the entire thing gets rerendered. Split up your component and have only the part that changes update itself.
Principles Worth Following
- Each component should do one thing well.
- Reuse components wherever possible; don't recreate the same markup in multiple places
- Keep your template logic in JavaScript and your rendering logic in templates
Example: A Small Reusable ProductCard Component
// ProductCard.js
export class ProductCard extends Component {
static template = 'my_module.ProductCard';
static props = {
name: String,
price: Number,
};
}
// ProductCard.xml
<t t-name="my_module.ProductCard">
<div class="product-card">
<span t-esc="props.name"/>
<span t-esc="props.price"/>
</div>
</t>
// Used in the parent template:
<ProductCard t-foreach="products" t-as="p"
t-key="p.id" name="p.name" price="p.price"/>
Each ProductCard instance manages itself. Updating one product's data only re-renders that card, not the entire list.
Using Services the Right Way
There is definitely a purpose for having a service layer at Odoo; it allows users to centralize backend calls and common logic instead of having to copy the same code into multiple objects. It means if there is some common logic that you are duplicating in three different objects, then it should be a part of a service.
It is not only code quality that services will improve, but also their performance. For example, a service has its own cache mechanism, which means it won't make redundant backend calls.
Example: orm and notification Services in Action
import { useService } from '@web/core/utils/hooks';
export class MyComponent extends Component {
static template = 'my_module.MyComponent';
setup() {
this.orm = useService('orm');
this.notification = useService('notification');
this.state = useState({ records: [] });
}
async loadData() {
this.state.records = await this.orm.searchRead(
'sale.order',
[['state', '=', 'sale']],
['name', 'partner_id', 'amount_total']
);
this.notification.add('Data loaded successfully', {
type: 'success',
});
}
}Advanced Techniques Worth Knowing
Once you've covered the basics, there are a few more techniques that make a noticeable difference in larger, more complex applications.
Debounce Search Inputs
A search field that fires an RPC call on every keystroke will hammer your server unnecessarily. Debouncing waits until the user pauses before sending the request a simple change that dramatically reduces backend load.
import { useService } from '@web/core/utils/hooks';
import { debounce } from '@web/core/utils/timing';
export class SearchComponent extends Component {
static template = 'my_module.SearchComponent';
setup() {
this.orm = useService('orm');
this.state = useState({ results: [] });
// Only fires 400ms after the user stops typing
this.onSearch = debounce(this._search.bind(this), 400);
}
async _search(query) {
this.state.results = await this.orm.searchRead(
'product.product',
[['name', 'ilike', query]],
['name', 'list_price']
);
}
}Share a Module-Level Cache Across Components
For truly static reference data things like currencies, countries, or units of measure a module-level cache means the data is fetched once per session, no matter how many components need it.
// shared_cache.js
const _cache = {};
export async function getCachedData(orm, model, fields) {
if (!_cache[model]) {
_cache[model] = await orm.searchRead(model, [], fields);
}
return _cache[model];
}
// In any component that needs it:
import { getCachedData } from './shared_cache';
async willStart() {
this.state.currencies = await getCachedData(
this.orm, 'res.currency', ['name', 'symbol']
);
}
OWL is truly well-designed, and all of the improvements in terms of performance basically boil down to just doing what OWL suggests, namely working with a lean state, computing data upfront, creating focused components, and doing efficient communication on the back-end. These improvements can be achieved without refactoring any of the code, but by simply following good practices right from the beginning.
When developing Odoo's frontend and running into performance issues, pick one of the topics discussed here and start making changes in your application from there. It often happens that a small improvement is much more effective than you expected it to be.
To read more about How to Create a standalone Owl application in Odoo 19, refer to our blog How to Create a standalone Owl application in Odoo 19.