Lit components fetch data from three tempting places β updated(), connectedCallback(), firstUpdated() β and two of them are traps. Fetch in updated() and you get a self-perpetuating request storm. Fetch in connectedCallback() and your request re-fires every time the element moves in the DOM. And once the data lands, this.items.push(...) silently updates the array while the rendered list stays frozen.
This guide pairs a plain Vite + Lit app with a hosted mock API and walks through each trap β every one reproduced and measured in a real Chrome on Lit 3.3.3 + @lit/task 1.0.3 before publishing. All numbers below (80 fetches, 3 vs 1 lifecycle calls, 0 re-renders after a push) are observed counts from an instrumented window.fetch and render counters, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, or anywhere else β no proxy config. When you want your own schema it's one curl or one click β see Β§10.
updated() fetch storm: 80 requests in 4 seconds, forever"Refresh the data whenever the component updates" sounds reasonable and is an infinite loop. updated() runs after every render; setting a reactive property inside it schedules the next render:
// THE BUG: fetch + property write inside updated()
import { LitElement, html } from 'lit';
class ProductList extends LitElement {
static properties = { items: { state: true } };
constructor() { super(); this.items = []; }
updated() { // runs after EVERY render
fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
.then(r => r.json())
.then(d => { this.items = d; }); // new array -> update -> updated() -> fetch -> ...
}
render() { return html`<p>${this.items.length} items</p>`; }
}
Measured: mounted once in a real Chrome, this component fired 80 fetches (and 80 renders) in the first 4 seconds β a second run counted 41 requests at the 2-second mark and 103 at 5 seconds, still climbing. It never stops: each response assigns a new array object, Lit sees a changed reference, re-renders, and updated() fires again. Against a real backend that's a self-inflicted DDoS; against the demo API it's a burned daily quota.
The fix is to fetch from somewhere that runs once β firstUpdated(), a constructor-started Task β or to guard updated() with changedProperties.has(...) if you really need reactive refetching.
connectedCallback fetched 3 times; firstUpdated fetched onceThe subtler trap: connectedCallback() is not "on mount, once". It fires every time the element is attached to a document β including when something moves it (drag-and-drop reordering, list resorting that reparents nodes, appending to a different container):
class UserCard extends LitElement {
connectedCallback() {
super.connectedCallback();
fetch('.../m/demo/products?limit=2'); // re-fires on every re-attach
}
firstUpdated() {
fetch('.../m/demo/products?limit=2'); // fires once per element, ever
}
}
Measured: we created the element, then moved it to another container and back β two appendChild calls, zero new elements. The connectedCallback fetch fired 3 times (initial attach + both moves); the firstUpdated fetch fired exactly once. If your list makes one request per row and a sort reparents the rows, connectedCallback fetching multiplies your traffic invisibly.
Rule of thumb: one-shot data loads belong in firstUpdated() (or a Task); connectedCallback is for wiring things that must re-attach, like event listeners β and they should be undone in disconnectedCallback.
push() that never rendersLit's reactivity is reference-equality on property assignment. Mutating an array in place changes nothing Lit can see:
addItem(p) {
this.items.push(p); // BUG: same array reference -> no update
}
addItemFixed(p) {
this.items = [...this.items, p]; // new reference -> renders
}
Measured: after loading 3 products, push() grew items.length to 4 while the DOM still showed 3 <li>s and the render counter didn't move (0 re-renders). Calling the spread version next rendered 5 items β the pushed one was in the array all along; Lit just never re-rendered. This is the number-one "my lit-element doesn't update" bug, and it's silent: no warning, no error, state and DOM simply disagree.
(Alternatives: call this.requestUpdate() after a mutation, or use immutable updates everywhere β the spread idiom above is what most Lit code settles on.)
@lit/task: the fetch primitive Lit actually shipsInstead of hand-rolling loading flags, Lit's official @lit/task package gives you a controller with initial / pending / complete / error states, automatic re-runs when its args change, and β the part everyone misses β a built-in AbortSignal:
import { Task } from '@lit/task';
class ProductSearch extends LitElement {
static properties = { q: { state: true } };
constructor() { super(); this.q = ''; }
_task = new Task(this, {
task: async ([q], { signal }) => {
const r = await fetch(
`https://mockbird.mockbird.workers.dev/m/demo/products?q=${encodeURIComponent(q)}&limit=20`,
{ signal } // aborted automatically when args change
);
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
},
args: () => [this.q] // change this.q -> task re-runs
});
render() {
return this._task.render({
pending: () => html`<p>loadingβ¦</p>`,
complete: (items) => html`<ul>${items.map(i => html`<li>${i.name}</li>`)}</ul>`,
error: (e) => html`<p>error: ${e.message}</p>`
});
}
}
Setting this.q = 'ergonomic' from an input handler is all it takes β the task re-ran on its own in our harness (fetch count went up by exactly one per change), no manual willUpdate bookkeeping.
The demo API can simulate every state your task.render() branches need, straight from the URL:
| State | URL | What we measured |
|---|---|---|
| loading | ?limit=5&mock_delay=2000 | renders pending for the full 2s, then complete with 5 items |
| data | ?limit=5 | complete, 5 items |
| error | ?limit=5&mock_status=500 | error branch with HTTP 500 |
| empty | ?category=nonexistent-cat | complete with [] β style your empty state, it's not the error state |
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_status=500"
curl -w "%{time_total}s\n" -o /dev/null -s \
"https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_delay=2000"
All four verified in the harness: loading β data:5, error:HTTP 500, empty. mock_delay and mock_status work on any endpoint β no separate fixtures.
Type "life", results are slow; type "way", results are instant. The slow response lands last and overwrites the fast one β the classic hand-rolled-search bug:
Measured (naive version, one fetch per keystroke, no abort): we issued a slow broad query (q=life + 2.5s simulated latency) then a fast one (q=way) 400ms later. Final rendered result: q=life's response β the stale query won, showing "life" results under a search box that says "way".
Measured (same sequence through @lit/task): changing this.q mid-flight aborted the first run (exactly 1 AbortError observed) and the final render was q=way's response β correct, with zero extra code, because Task cancels the previous run's signal whenever its args change.
?mock_delay=2500 is how you make this reproducible instead of "sometimes flaky on hotel wifi": pin the latency on the first request, race it deliberately, assert the right result renders.
X-Total-Countasync more() {
this.page++;
const r = await fetch(`.../m/demo/products?_page=${this.page}&_limit=10`);
this.total = Number(r.headers.get('X-Total-Count')); // CORS-exposed
this.items = [...this.items, ...await r.json()]; // new reference (see Β§4)
}
Measured: three clicks loaded 10 β 20 β 30 of 30, the button's ?disabled flipped to true at the boundary, and an id-set check found 0 duplicate records across pages. X-Total-Count is exposed via CORS so r.headers.get() works cross-origin β many real APIs forget that and the header silently reads null.
?mock_seqRetry logic tested against a healthy API is untested. mock_seq serves an exact status script β fail, fail, succeed:
curl -s -o /dev/null -w "%{http_code}\n" \
"https://mockbird.mockbird.workers.dev/m/demo/products?limit=2&mock_seq=503,503,200&mock_seq_key=me1"
Measured: our fetchWithRetry helper observed exactly [503, 503, 200] β deterministic, not "randomly fails sometimes" like chaos flags. On the shared demo the counter is scoped per client IP (and mock_seq_key isolates parallel workers), so the link above starts fresh for you too. Statuses β₯400 are simulated before processing, so a failed write is never half-applied β safe to point real retry logic at.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments β or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Ship day is an env-var change: VITE_API_URL=https://api.yourcompany.com.
POST/PATCH/DELETE actually stick (unlike JSONPlaceholder's write theater) β build optimistic updates and verify against reality.POST /m/<project>/auth/login returns a real signed JWT for any email+password β test login flows and 401 handling without a backend (guide).GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod schemas) β typed Task<Product[]> results for free.beforeEach β deterministic Playwright/Cypress runs (guide).| Tool | Good at | Where this differs |
|---|---|---|
| MSW (Mock Service Worker) | In-process request interception for unit tests; no network at all | MSW mocks live inside each test runner. Mockbird is a real hosted URL β the same mock serves your browser, your CI, a StackBlitz repro, and a teammate's machine with zero setup. Use MSW for unit tests, a hosted mock for everything shared. |
| json-server | Local full-featured fake REST; huge ecosystem | Needs Node running on every machine that wants the API. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted β and adds auth simulation, failure injection, snapshots. |
| @lit/task's built-in demo servers | lit.dev tutorials use static JSON files | Static files can't simulate latency, failures, pagination headers, or persistent writes β the exact states this guide measures. |
Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements β rerun them in your own Chrome with the snippets as written.