Throughout the course, several decisions have come up in passing that had something to do with performance: repeat with a stable key instead of Array.map for lists that change, willUpdate to avoid recalculating the same thing on every render(), a deliberately small bundle thanks to Lit's minimalist design. Each one was explained at the time focused on the specific problem it solved, without pausing on the common thread connecting them. This lesson picks up those already-known pieces, lines them up side by side with an explicit performance lens, and applies them by reviewing how <task-list> and <task-board> would behave against a task list much larger than the three or four examples handled so far.
Contents
- Why the performance of a reactive component isn't a separate topic
- Minimizing work inside
render() - The hidden cost of creating new functions and objects on every render
repeatwithkey: recap with a genuinely large listwillUpdatefor derived calculations: recap with a cost-based criterion- Bundle size, revisited from a performance angle
- Simulating a large list in TaskFlow
- Fine-tuning
<task-list>and<task-board>'srender() - When none of these techniques is enough: virtualization
- Why the performance of a reactive component isn't a separate topic
In a Lit component, performance isn't a layer added at the end, separate from the rest of the design: it's interwoven with the same decisions already made throughout the course about what lives in a reactive property, what lives in render(), and what lives in an update-cycle hook. A component poorly designed in terms of responsibilities —business logic mixed with the template, calculations repeated unnecessarily— almost always turns out, as a side effect, to also be a slow component; and, conversely, the techniques that improve performance (moving a calculation to willUpdate, giving each list item a stable identity) usually coincide with the ones already recommended for clarity and correctness, not just for speed.
This lesson introduces no new Lit concept: it reorders and applies with intent four pieces the course has already explained separately, now under the explicit question of "what does this cost the browser, and how is that cost reduced without sacrificing any of the behavior already built?"
- Minimizing work inside
render()
render()render() potentially runs on every component update: every change to a single reactive property, however tiny, triggers a full new call to this method. Any expensive calculation placed directly inside render() therefore repeats as often as the rendering itself, even on updates that have nothing to do with that particular calculation.
// Avoid: recalculates something expensive on every render(), regardless of what changed
render() {
const tareasOrdenadas = [...this.tareas].sort((a, b) => b.prioridad - a.prioridad);
return html`
<ul>
${tareasOrdenadas.map((tarea) => html`<li>${tarea.titulo}</li>`)}
</ul>
`;
}For a list of three or four items, like the ones TaskFlow has handled for most of the course, re-sorting on every render() is, in practice, free: no user would notice the difference. But the same pattern, applied to a list of several thousand tasks (the scenario explored in section 7), turns an O(n log n) sorting operation into a cost that repeats on every keystroke in the filter, every priority change on a single task, every update that, for any reason, triggers a new render() of the component holding this logic. The general rule, already pointed out in the "Rendering Lists" lesson from module 2, is that render() should be limited, as much as possible, to turning already-prepared data into HTML, not preparing that data from scratch every time.
- The hidden cost of creating new functions and objects on every render
A second, more subtle pattern shows up whenever a template creates a new function —usually an arrow function— directly inside a render() expression:
${['todas', 'pendiente', 'hecha'].map(
(opcion) => html`
<button @click="${() => this.manejarEstado(opcion)}">
${opcion}
</button>
`
)}This is, in fact, exactly the <task-filter> code introduced in the "Shared Context with @lit/context" lesson: every time render() runs, map builds three new arrow functions, one per button, none of which is the same function reference that existed on the previous render. Lit, when applying the result to the DOM, detects that the event handler has "changed" (even though it does exactly the same thing) and replaces the previous listener with the new one, instead of reusing it. For three buttons, this cost is negligible; for a list of hundreds or thousands of elements, each with its own inline handler, the cost of creating and replacing those functions on every update starts to show.
The alternative, when possible, is to use a single bound class method instead of a function created on every iteration:
manejarEstado(event) {
const estado = event.currentTarget.dataset.estado;
this.valorActual.actualizar({ estado });
}
render() {
const { estado } = this.valorActual;
return html`
${['todas', 'pendiente', 'hecha'].map(
(opcion) => html`
<button data-estado="${opcion}" @click="${this.manejarEstado}" class="${classMap({ activo: estado === opcion })}">
${opcion}
</button>
`
)}
`;
}Here, @click="${this.manejarEstado}" always references the same class method (Lit automatically preserves the value of this inside handlers declared this way, as already explained in the "Handling DOM Events in Templates" lesson), without creating any new function on every render(); the data that used to be captured through the arrow function's closure (opcion) is now retrieved from event.currentTarget.dataset.estado, using a data-* attribute on the button itself. This rewrite has a real trade-off worth weighing, not applying blindly: the code ends up somewhat less direct to read than the inline arrow function version, and for small element volumes (the three <task-filter> buttons, or the @tarea-cambiada handler with tarea.id captured in the "Parent-to-Child Communication with Properties" lesson) the performance gain is, in practice, undetectable. The sensible criterion is to reserve this kind of rewrite for lists that genuinely grow (dozens or hundreds of elements), not to apply it systematically to <task-filter>'s three buttons, where the clarity of the inline arrow function is still worth it against savings nobody would notice.
repeat with key: recap with a genuinely large list
repeat with key: recap with a genuinely large listThe "Rendering Lists" lesson from module 2 introduced the identity problem when reordering a list rendered with Array.map, and the "Shared Context with @lit/context" lesson from module 7 finally applied repeat with tarea.id as the key inside <task-list>, precisely so the filter could insert and remove visible cards without losing the internal state of the ones that remained. With a list of three or four tasks, the difference between map and repeat is, in terms of raw cost, negligible; with a list of several thousand tasks, the difference becomes decisive.
| Scenario | With Array.map |
With repeat + key |
|---|---|---|
| The filter excludes 500 of 2000 tasks | Lit compares by position: it can end up rebuilding a large part of the 1500 visible cards, even though most are the same tasks from before in a different position | Lit recognizes, by id, which cards are the same as before; it only destroys the nodes of the 500 that fall out of the filtered result |
| A new task is inserted at the start of 2000 | All 2000 positions shift; risk of extensive rebuilding | Only one new node is created at the start; the existing 2000 aren't touched |
A card's internal state (expandida) while the filter changes |
Can be lost if Lit reuses that position's physical node for a different task | It's preserved as long as the task keeps matching the filter, regardless of its position |
<task-list> has already used repeat since module 7, so no code change is needed in this section; what this lesson contributes is the real magnitude of that decision, visible only once the data volume stops being the handful of example tasks handled for most of the course.
willUpdate for derived calculations: recap with a cost-based criterion
willUpdate for derived calculations: recap with a cost-based criterionThe "Reactive Hooks" lesson from module 6 introduced willUpdate as the right place to recalculate cercaDeVencer only when fechaLimite changes, rather than on every render(), and closed that section by pointing out the cost of the alternative: recalculating on every render, including those that have nothing to do with the deadline. That same reasoning, applied now to this lesson's question, is a perfect example of the general criterion from section 2: any derived calculation depending on a specific subset of properties should live in willUpdate, guarded by changedProperties.has(...), not repeat unconditionally inside render().
The same reasoning applies to tareasFiltradas in <task-list> (from the "Shared Context with @lit/context" lesson), declared as a getter that recalculates the full filtering every time it's read, even from within render() itself:
// As it stood in module 7: recalculated every time it's read
get tareasFiltradas() {
const { texto, estado } = this._filtro.value ?? { texto: '', estado: 'todas' };
const textoNormalizado = texto.toLowerCase();
return this.tareas.filter((tarea) => {
const coincideEstado = estado === 'todas' || tarea.estado === estado;
const coincideTexto = tarea.titulo.toLowerCase().includes(textoNormalizado);
return coincideEstado && coincideTexto;
});
}For the volume of tasks handled so far, this getter is perfectly reasonable as it is: it's read exactly once per render() (not several times within the same method, which would needlessly double the cost), and the filtering itself, over few elements, is practically instant. Section 7 revisits this same getter against a much larger list, to decide with data whether it's worth moving it to willUpdate or whether, even at greater scale, leaving it as is remains acceptable.
- Bundle size, revisited from a performance angle
The "Bundling, Publishing, and TypeScript" lesson from module 8 explained why Lit is deliberately small and why that benefits TaskFlow's initial load time. That explanation focused on the time until the first component gets defined; the same reasoning has a second side relevant to this lesson: the more dependencies a project adds on top of Lit (a generic array-utility library, an additional UI component framework, a full icon library when only three icons are used), the larger the final bundle, and the longer the browser needs to download, parse, and execute it before any TaskFlow component —including this very module's optimizations— can even start rendering. No render() or repeat optimization makes up for an unnecessarily large initial bundle: they're complementary concerns, not substitutes, and both are worth reviewing before considering an application's performance settled.
- Simulating a large list in TaskFlow
To reason with real data rather than intuition, it's useful to generate a task list far larger than what's been handled so far and observe how <task-list> behaves against it:
function generarTareasDeEjemplo(cantidad) {
const estados = ['pendiente', 'en-progreso', 'hecha'];
return Array.from({ length: cantidad }, (_, indice) => ({
id: indice + 1,
titulo: `Tarea de ejemplo número ${indice + 1}`,
estado: estados[indice % estados.length],
prioridad: (indice % 5) + 1,
urgente: indice % 7 === 0,
}));
}
const board = document.querySelector('task-board');
board.tareas = generarTareasDeEjemplo(2000);With 2000 tasks assigned all at once to <task-board> (which forwards them, as it has since module 5, to <task-list>), the first full render —2000 <task-card> instances, each with its own Shadow DOM, its own internal <user-avatar>, and its own ContadorTiempoRestanteController— is, by far, the most expensive moment of the whole interaction: creating thousands of custom elements at once, each with its own full lifecycle, has a real cost that none of this lesson's techniques eliminates entirely, because it doesn't depend on how the list is traversed but on how many distinct components must be instantiated. The following sections focus instead on what can actually be controlled: what happens on updates after that first render, which is where repeat, willUpdate, and avoiding unnecessary work in render() make the real difference.
- Fine-tuning
<task-list> and <task-board>'s render()
<task-list> and <task-board>'s render()With the 2000 tasks already loaded, typing a character in <task-filter>'s search field triggers, in cascade, a new evaluation of tareasFiltradas in <task-list> every time that component re-renders. The getter from section 5, as it stands, walks the entire 2000-task array on every keystroke; with Array.filter, that traversal has linear cost (O(n)) relative to the total number of tasks, not relative to the number of visible tasks, so the cost doesn't depend on how many results remain, but on how many tasks exist in total.
At this scale, that linear cost is still acceptable in practice (a filter over 2000 elements with simple text and equality comparisons runs, on any modern browser, in roughly a millisecond, well below the threshold a person can perceive), so moving tareasFiltradas to willUpdate is not needed in this particular case: it would be a real optimization, but one solving a problem that, at this scale, isn't causing any perceptible slowness. Applying section 1's criterion honestly means, here, recognizing that the already-known technique (willUpdate) remains available, but that introducing it without a measurable problem would add complexity with no real benefit.
Where a perceptible difference does show up, however, is exactly in section 4: checking, with the browser's developer tools, how many DOM nodes are created or destroyed when typing in the filter over the 2000 tasks. With repeat and tarea.id as the key —already in use since module 7— typing a letter that reduces the result from 2000 to 340 matches destroys only the nodes of the 1660 cards that stop matching the filter, without touching the nodes of the 340 that remain visible; reverting the change (deleting the typed letter) recreates those same 1660 cards, which aren't recovered from any kind of cache, because repeat doesn't keep in memory the nodes of elements that no longer appear in the received array. This observation requires no additional code change on top of what was already built in module 7: it confirms, at a much larger data scale, that the decision made back then was correct, and that replacing repeat with Array.map at this scale would indeed be perceptible, by forcing position-based comparisons over thousands of elements on every keystroke.
- When none of these techniques is enough: virtualization
The techniques in this lesson reduce the cost of updating a long list that changes, but they don't reduce the cost of the first full render noted in section 7: creating 2000 <task-card> instances at once remains costly, no matter how well optimized the rest of the code is. For data volumes where that first cost becomes unacceptable (tens of thousands of elements, not the thousands in this example), the usual technique, mentioned here only for reference and outside this course's practical scope, is virtualization: rendering in the DOM only the elements falling within (or near) the visible area at any given moment, and creating or destroying the rest dynamically as the user scrolls through the list, instead of keeping thousands of real nodes existing simultaneously even though the vast majority aren't visible at any given moment. TaskFlow, with the reasonable data volumes for a team's task-management application, doesn't need to go to that extreme, but it's worth knowing the technique exists should the project grow well beyond what this course covers.
Common Mistakes and Tips
- Optimizing without measuring first: as seen in section 8, moving
tareasFiltradastowillUpdatewithout first checking whether the actual cost offilterover the current data volume is perceptible adds complexity with no demonstrated benefit; measuring before optimizing avoids this kind of wasted effort. - Replacing every inline arrow function with a bound method "just in case": as explained in section 3, this rewrite has a real readability cost, and only delivers a measurable benefit when the number of affected elements is high; applying it to
<task-filter>'s three buttons would be exactly the same mistake of optimizing without a real need. - Confusing the cost of the first render with the cost of later updates: as noted in section 7, no technique in this lesson reduces the cost of creating thousands of components for the first time;
repeat,willUpdate, and avoiding unnecessary work inrender()optimize the updates that come after that first render, they don't replace it. - Recalculating the same getter several times within the same
render(): ifrender()calledthis.tareasFiltradastwo or three times (for example, once to count the result and again to iterate over it), the filtering cost would multiply with no need; it's better to read the getter once into a local variable and reuse that variable within the samerender().
Exercises
- Rewrite
<task-list>'stareasFiltradasgetter so that, instead of running on every read, it recalculates insidewillUpdateonly whenchangedPropertiesincludestareas, storing the result in an internal_tareasFiltradasCachestate. Explain, drawing on section 8, in which real usage scenario this version would stop being an improvement and start introducing a problem (hint: think about what triggers a new filtering today thattareasalone wouldn't capture). - A teammate, after reading section 3, rewrites every inline click handler in TaskFlow (including
<task-filter>'s and<task-card>'s "Eliminar tarea") as bound methods withdata-*attributes, without measuring any real impact beforehand. Explain, based on section 1 and section 3 itself, whether this decision is justified as described. - Explain, based on section 9, why virtualizing
<task-list>would not, on its own, solve the first-render cost described in section 7 if all 2000 tasks were assigned at once and the entire list (with no user scrolling) were shown in full from the very first instant.
Solutions
static properties = {
tareas: { type: Array },
_tareasFiltradasCache: { state: true },
};
willUpdate(changedProperties) {
if (changedProperties.has('tareas')) {
this._tareasFiltradasCache = this._calcularTareasFiltradas();
}
}
_calcularTareasFiltradas() {
const { texto, estado } = this._filtro.value ?? { texto: '', estado: 'todas' };
const textoNormalizado = texto.toLowerCase();
return this.tareas.filter((tarea) => {
const coincideEstado = estado === 'todas' || tarea.estado === estado;
const coincideTexto = tarea.titulo.toLowerCase().includes(textoNormalizado);
return coincideEstado && coincideTexto;
});
}The problem with this version is that the filter doesn't change only when tareas changes: it also changes when the user types in <task-filter> or presses one of its buttons, a change that reaches <task-list> through the ContextConsumer subscribed to the filter context, not as a reactive property declared in <task-list>'s static properties. willUpdate with changedProperties.has('tareas') would never see this second kind of change (the context alone doesn't trigger a changedProperties with the tareas key), so the cache would go stale the moment the user touched the filter, always showing the previous filter's result until tareas changed for some other reason. Solving it correctly would require, at minimum, also recalculating when the context value changes, which in practice brings back much of the complexity this "optimization" was meant to avoid, reinforcing section 8's conclusion: for TaskFlow's current volume, the original getter, without a cache, remains the simplest and sufficiently fast option.
- It is not justified as described. Section 1's criterion demands measuring before optimizing, and section 3 is explicit that this rewrite only delivers a real benefit for lists with a high number of elements; neither
<task-filter>'s three buttons nor each<task-card>'s single "Eliminar tarea" button (which is already created once per card, not in an inner loop) fit that profile. Applying the rewrite systematically, without measuring, sacrifices the clarity of inline arrow functions in exchange for a performance saving that's undetectable in practice, exactly the first mistake flagged in this lesson's list of common mistakes. - Virtualization reduces the number of elements existing simultaneously in the DOM at a given moment, creating and destroying nodes as the user scrolls; but if all 2000 tasks must be shown visible from the very first instant, with no scrolling that limits which part of the list is relevant at any given time, virtualization has nothing to "not show yet": the interface's own requirement (seeing all 2000 at once) forces the creation of all 2000 real components regardless of the rendering technique used. Virtualization helps precisely when most of the content isn't visible at a given moment (very long lists where the user only sees a small window at a time); it doesn't help when the interface's own design requires showing everything simultaneously.
Conclusion
This lesson introduced no new Lit concept; instead, it put into perspective, with an explicit performance lens, four decisions TaskFlow had already made for other reasons throughout the course: minimizing work inside render(), measuring before replacing inline functions with bound methods, trusting repeat with a key for lists that change size, and remembering that none of these techniques replaces a reasonably small initial bundle. Against a simulated list of 2000 tasks, those decisions —already made in modules 2, 6, 7, and 8— have proven to still be the right ones, with no need to rewrite anything else except where the analysis itself justified it with data, not intuition.
With performance now reviewed with judgment, one last lesson remains in this module before the final project: a cross-cutting review of recommended patterns versus anti-patterns, which walks through TaskFlow from start to finish and serves as a direct bridge to the course's closing module.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
