Skip to content

Optimistic updates with Undo: how my Base44 admin feels instant

My Base44 admin feels instant because the screen changes first and the save comes second. The pattern with real code: patch every react-query cache, write 3 at a time with backoff on 429s, refresh once, roll back on failure, Undo that restores every field, inbox shortcuts, and when not to be optimistic.

Aatman (AJ) Jain9 min readUpdated 4 Oct 2026
An index card with a big black tick, an orange undo arrow curling back from it and a pencil with its eraser ready, beside a lightning bolt sticker, on marigold paper.
An index card with a big black tick, an orange undo arrow curling back from it and a pencil with its eraser ready, beside a lightning bolt sticker, on marigold paper.

To make a Base44 admin feel instant, change the screen first and save second: patch every cached copy of the record in react-query, write through the SDK, then refresh once at the end. If the save fails, that one refresh is your rollback, and an Undo toast that puts back every field the change touched covers the times it was you who got it wrong.

It all comes from my own admin: the inbox where my contact form lands, and the pipeline on top of it (how that started). The code is real, cut short.

The refresh that ran 20 times

I built the first version of my inbox on 26 Sep. It saved each message through a shared hook, which is fine, except that hook refreshed every list after every single write. Archive one message? No problem. Select 20 and archive them all? That's 20 writes and 20 refreshes, and on that page each refresh is two requests: the list and the "new" badge count.

So I measured it. Same 20 test messages, same mocked Base44 API with 150 ms on every request, three runs each. The only difference was when the refresh happens.

Archiving 20 inbox messages

Refresh after every writeRefresh once at the endLower is better
Requests sent6222
Archiving 20 inbox messages, lower is better
RowRefresh after every writeRefresh once at the endChange
Requests sent622265% lower

Playwright against my admin on a local dev server, with a mocked Base44 API (150 ms per request) and 20 test messages, 3 runs each. Once at the end: 20 writes + 1 refresh (2 reads), the real code. After every write: the same module patched in the browser only, 20 writes + 42 reads. Median time to the last response: 1.35 s against 3.03 s.

40 extra requests that tell you nothing new. On a backend with a rate limit, that's budget you'll want later. The fix went in the next day, 27 Sep, and it's the pattern this whole post is about.

Patch, save, refresh once

One change, start to finish. The three numbered steps are the whole pattern.

Pessimistic: save, then show

  • You click, then watch a spinner until Base44 answers
  • A refresh after every row, so 20 rows means 20 refreshes
  • One slow request makes every click feel slow
  • Bulk actions burn through the rate limit

Optimistic: show, then save

  • The card moves the moment you drop it
  • Writes go out 3 at a time, then one refresh for the batch
  • A failed save rolls back with that same refresh, plus an error toast
  • Undo puts back every field the change touched

Pessimistic isn't wrong, and the screen can never lie to you. But in an admin I use every day, the waiting adds up.

Step 1: patch every cached copy

One client can sit in several caches at once: the list, the board, the client sheet and the "Needs you" panel. Patch one and the others contradict it. So I walk every query under the entity's key and patch whatever holds that id:

js
// src/components/admin/crm/useClientActions.js
const patchCaches = useCallback(
  (ids, patchFor) => {
    const set = new Set(ids);
    queryClient.cancelQueries({ queryKey: KEY });
    for (const [key, data] of queryClient.getQueriesData({ queryKey: KEY })) {
      if (Array.isArray(data)) {
        const next = [];
        for (const row of data) {
          if (!set.has(row?.id)) {
            next.push(row);
            continue;
          }
          const patch = patchFor(row);
          if (patch !== null) next.push({ ...row, ...patch });
        }
        queryClient.setQueryData(key, next);
      } else if (data && typeof data === 'object' && set.has(data.id)) {
        const patch = patchFor(data);
        queryClient.setQueryData(key, patch === null ? null : { ...data, ...patch });
      }
    }
  },
  [queryClient]
);

Two details matter. cancelQueries goes first, for the reason TanStack Query's guide (opens in a new tab) gives: cancel any outgoing refetches "so they don't overwrite our optimistic update". And patchFor can return null to drop a row, which is how a deleted client vanishes from every list at once.

The inbox version also nudges the badge counts, so the badge never says 3 while the list shows 2.

Step 2: save through a small pool, and back off on 429s

Every write goes through one helper, writeEach, in my admin kit. At most 3 requests in flight, one result per row in the same order, and it never throws. The interesting bit is the retry:

js
// src/components/admin/kit/adminData.js
const retryable = (error) => {
  const status = statusOf(error);
  return status === 429 || status === 502 || status === 503 || status === 504 || (!status && !error?.offline);
};

// inside each of the (at most 3) workers
for (let attempt = 0; ; attempt += 1) {
  try {
    const data = await write(list[i], base44.entities, i);
    results[i] = { ok: true, data };
    break;
  } catch (error) {
    if (attempt < retries && retryable(error)) {
      // 429s come with no Retry-After header, so back off: 1.5 s, 3 s, 6 s.
      await pause(1500 * 2 ** attempt);
      continue;
    }
    results[i] = { ok: false, error: friendlyError(error), status: statusOf(error) };
    break;
  }
}

Its docblock calls a 429 the "Base44 rate limit, about 150 requests a minute". Before writing this I checked that against Base44's own docs. The troubleshooting page (opens in a new tab) confirms the shape but not the number: "Rate limits apply per person", and the advice is to "Add retry logic that waits a short time before sending a new request after a 429 response." There's no requests-a-minute figure for your app's own data calls. My Base44 notes say 150 a minute per user, plus 150 a minute for the service role, so that's what I design for. Treat it as a guide, not a promise.

writes in flight, never more
3
tries per write before it gives up, over about 10 s
4
requests a minute per user, the figure I design for
150
to hit Undo after a stage move
6 s

Here's the backoff when I force my mock of Base44 to say no every time. I've cut the log down to this one message's own requests:

Terminal
node retry-check.cjs--- give-up: 429 every time 0.00 s  PUT ContactMessage/m1  429 Too Many Requests 1.54 s  PUT ContactMessage/m1  429 Too Many Requests 4.54 s  PUT ContactMessage/m1  429 Too Many Requests10.55 s  PUT ContactMessage/m1  429 Too Many Requests10.56 s  GET ContactMessage/count  (refresh)10.57 s  GET entities/ContactMessage  (refresh)11.80 s  row in the inbox list: yes; saved status: read         toasts: ["Too many requests. Wait a moment and try again."]

Four tries in about 10 seconds, then one refresh puts the row back and the toast says why. The lines I cut get their own section below.

Why not one bulk call? Base44's SDK has bulkUpdate (opens in a new tab) (up to 500 records a request) and updateMany (the same change for everything matching a query). If every row gets the same change and you don't need to know which one failed, use them. I wanted a result per row, so the error toast can say what broke. One gotcha either way: Base44's automation docs (opens in a new tab) say bulk and by-query writes don't trigger entity automations.

Step 3: refresh once, and let it be the rollback

js
// src/components/admin/inbox/useInboxActions.js
const save = useCallback(
  async (messages, status) => {
    const results = await writeEach(messages, (m, api) => api.ContactMessage.update(m.id, { status }));
    // One refresh for the whole batch (lists, badge, dashboard); on failure it also rolls back.
    invalidate();
    const failed = results.filter((r) => !r?.ok);
    if (failed.length) {
      notify(failed[0].error || "Couldn't save that. Try again?", 'error');
      return { ok: false };
    }
    return { ok: true };
  },
  [invalidate]
);

That comment is the idea I'd keep from this post. One refresh, after the batch, and it's also the rollback. I don't snapshot the cache before a change. If a write failed, the refresh pulls whatever Base44 actually has, and that's the truth by definition. TanStack's guide backs this up: "In most of these failure cases, you can just trigger a refetch for your optimistic queries to revert them to their true server state."

The catch, which the guide also points out: if the refresh can't reach the server either, nothing rolls back. The error toast still fires, so at least you know.

The hole I found writing this

Here's the other run in full, where the second retry works:

Terminal
node retry-check.cjs--- recover: 429, 429, then 200 0.00 s  PUT ContactMessage/m1  429 Too Many Requests 0.15 s  row in the inbox list: yes 0.45 s  row in the inbox list: no 0.52 s  PUT ContactMessage/m2  (another action) 0.56 s  GET ContactMessage/count  (refresh) 0.56 s  GET entities/ContactMessage  (refresh) 0.90 s  row in the inbox list: yes 1.53 s  PUT ContactMessage/m1  429 Too Many Requests 2.00 s  row in the inbox list: yes 3.00 s  row in the inbox list: yes 4.54 s  PUT ContactMessage/m1  200 4.56 s  GET ContactMessage/count  (refresh) 4.57 s  GET entities/ContactMessage  (refresh) 5.80 s  row in the inbox list: no; saved status: archived         toasts: ["Archived. Undo"]

The row slides out (gone by 0.45 s), then it's back at 0.90 s and still there at 3 s. Why? Archiving opens the next message, and opening an unread message marks it as read. That's a second save with its own refresh. It asks Base44 for the list while the first archive is still waiting out its backoff, Base44 says "that one's still in the inbox", and the archived row pops back. Nothing is lost. It just looks wrong.

The fix I'd make: count the writes in flight for each entity, and only refresh when that count hits zero. If you're copying this pattern, build that in from day one.

Undo that puts everything back

A stage move isn't one field. Moving a client to Proposal sent changes the stage, resets the win chance to that stage's default (60%), stamps stage_changed_at, adds a "Call booked → Proposal sent" entry to the timeline, and can set or clear the won and lost dates. An Undo that only flips the stage back leaves the rest of them wrong.

So before the move, I record the old value of every key the patch is about to touch:

js
// src/components/admin/crm/useClientActions.js (moveStage)
const patches = new Map(list.map((c) => [c.id, stagePatch(c, stage, now)]));
const befores = new Map(
  list.map((c) => {
    const keys = Object.keys(patches.get(c.id));
    return [c.id, Object.fromEntries(keys.map((k) => [k, c[k] ?? (k === 'activities' ? [] : '')]))];
  })
);
patchCaches([...patches.keys()], (row) => patches.get(row.id));
const res = await save(list.map((c) => ({ id: c.id, patch: patches.get(c.id) })));
if (!res.ok) return res;
// (won deals also mark their inbox messages as won leads, then the toast)
toastWithUndo(message, async () => {
  patchCaches([...befores.keys()], (row) => befores.get(row.id));
  const undone = await save(list.map((c) => ({ id: c.id, patch: befores.get(c.id) })));
  // (and linked inbox messages get their old rating back)
  if (undone.ok) notify('Undone.', 'success');
});

The test client in these screenshots had a hand-set 45% chance, not Call booked's default of 40%. Undo brings back the 45%, the old date and the old timeline, because it doesn't work anything out. It just puts back what was there.

Moved with the keyboard: Space, right arrow, Space.
After Undo: back to the hand-set 45%, not the stage default.

The inbox does the same for bulk changes. Select a mix of new and read messages, archive them, hit Undo, and each one goes back to its own old status, not one status for all of them.

And give people time to read the toast. My pipeline's Undo toasts stay up for 6 seconds. While timing them for this post, I noticed the inbox's still used the site toaster's 3.2 second default. That's tight when you're mid-thought, so they're 6 seconds now too.

Keyboard shortcuts in the inbox

Optimistic updates are what make a keyboard inbox work. Press E and the message is archived and the next one is open before the request has even come back. Press J and you're reading the one after. No waiting between keys.

My inbox, test data. The hint strip only shows on devices that can hover, so not on phones.

J and K move through the list, E archives, U toggles unread, R opens a reply in your email app, ! is spam, # deletes, C copies the email and Escape closes the split view. E, U and ! toggle, so E on an archived message puts it back.

It's one listener on the window with a guard at the top, so typing a reply never archives anything:

js
// src/pages/admin/Messages.jsx
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target;
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
if (document.querySelector('[role="alertdialog"], [role="menu"]')) return;

The toolbar buttons carry aria-keyshortcuts, so screen readers that support it can announce the key. The hint strip itself is aria-hidden, since the buttons already say it. The pipeline board has its own keyboard story (Space to lift a card, arrows to move it, Space to drop), which is in my drag-and-drop post.

When not to be optimistic

Optimistic is a promise: this will probably work, and if it doesn't, I'll put it back. Some things can't be put back.

Deletes ask first, with Cancel focused
Sent but not logged: the box is empty, so no second send

Deletes

In the inbox and the pipeline, every delete goes through a confirm dialog first, and anything over 3 rows makes you type to confirm. The inbox goes further: a message only leaves the screen after Base44 confirms the delete. There's no bin, so I'd rather wait for Base44 than show you something that didn't happen.

Anything you can't unsend

Replies to Base44 Partner leads go out through my partnerReply backend function, which posts the reply to Base44 and then logs it on the client's timeline (the webhook post covers the other direction). If the post works but the log fails, the worst thing the screen could do is say "failed" and keep your text in the box. You'd hit Send again, and the lead would get the same message twice.

The reply went out; only the log failed. Say so rather than inviting a second send.

A comment in my partnerReply function

So the function answers ok: true, logged: false with a warning. The page clears the box and says "Sent, but it couldn't be added to the timeline." The part that matters already happened, so the screen shouldn't pretend it didn't. Same spirit as the save-first rule in my SendEmail post: know which part is the real work and which part is the extra.

The recipe

  1. Find every cached copy

    queryClient.getQueriesData({ queryKey: ['admin', 'Client'] }) returns every list and single record under that key.

  2. Cancel refetches in flight

    cancelQueries first, so an old answer can't land on top of your patch.

  3. Write down the before

    The old value of every field the change will touch, side effects included.

  4. Patch, then save

    setQueryData on each copy, then write through the SDK, 3 at a time.

  5. Back off on 429s

    Wait 1.5 s, 3 s, then 6 s. After that, give up and say so.

  6. Refresh once

    One invalidate for the whole batch. If anything failed, that refresh is the rollback.

  7. Offer Undo

    Patch back to the before, save it, refresh once. Give the toast 6 seconds.

  8. Know when not to

    Deletes get a confirm. Sends never get a blind retry.

The speed is nice. The bit I actually care about is trust: the screen does what I asked straight away, and when it can't, it tells me and puts things back. Build the putting-back first. The fast bit is easy after that.

FAQ

What is an optimistic update in React Query?

An optimistic update changes the cached data on screen before the server confirms it, then saves in the background. If the save fails, you roll back, either from a snapshot or by refetching so the server's copy wins. TanStack Query's guide says a refetch is enough for most failures, which is what my Base44 admin does.

What is Base44's rate limit?

Base44's docs say rate limits apply per person and that you should wait and retry after a 429, but they don't publish a requests-a-minute figure for an app's own data calls. I design for about 150 requests a minute per user: 3 writes in flight, one refresh per batch, and pauses of 1.5, 3 and 6 seconds after a 429.

Should every admin action be optimistic?

No. Status changes, stage moves and edits are good candidates because they're easy to reverse. Deletes, sent messages and anything involving money should wait for the server, go through a confirm dialog, or both.

How do I make Undo restore everything?

Before you patch, record the old value of every field the change will touch, including side effects like dates, win chances and timeline entries. Undo then patches the caches back to those values, saves them and refreshes once, the same way the original change did.

Keep reading

More posts on the same things.

Newsletter

Liked this one?

Get my Friday email: new posts, what I built and the Base44 and AI news worth knowing.

No spam. Unsubscribe whenever.

Got an app in mind?

I build and fix Base44 apps, and I'm looking for a remote internship. Tell me what you're working on.