Skip to content

Instantly share code, notes, and snippets.

@futzlarson
Created April 8, 2026 17:29
Show Gist options
  • Select an option

  • Save futzlarson/6a6347d7cbdde1f76288f5776dbeaf7b to your computer and use it in GitHub Desktop.

Select an option

Save futzlarson/6a6347d7cbdde1f76288f5776dbeaf7b to your computer and use it in GitHub Desktop.
Assessment autosave review (PR #2929) — performance concerns

Assessment Autosave Review (PR #2929)

PR: https://github.com/curiticshealth/Care-Management/pull/2929 Merged: 2026-03-25

Context

The assessment component is the highest-volume transaction in the app — 1.8M Livewire interactions in 2 days (Sentry data). PR #2929 added server-side autosave that fires afterStateUpdated on every form field.

Concerns

1. Every field change triggers a full save — no debounce on most field types

afterStateUpdated is added to every assessment field in HasAssessment::createAssessmentFormField():

$field->afterStateUpdated(function () {
    $this->autoSave();
});

Only text fields got ->live(debounce: 500). Selects, toggles, checkboxes, and radios are ->live() with no debounce — they trigger autoSave() immediately on every click. A 30+ field assessment means 30+ Livewire round-trips during a single form fill.

2. autoSave queries the DB on every call

Inside autoSave():

$inDraftStatus = MasterAssessmentStatus::where('name', 'Draft')->where('is_active', true)->first();

This runs a raw query every time status_id is null. Could use MasterAssessmentStatus::idByName('Draft') (from the CachesIdByName trait added on the performance branch) to eliminate this query.

3. form()->getRawState() rebuilds the form on every autosave

$this->form->getRawState() triggers Filament's form hydration, which runs all closures in form() — including master data queries:

  • MasterSmartText::pluck(...) — loads all smart texts
  • MasterNoteDocStatus::where('name', 'Final')->value('id')
  • MasterNoteDocStatus::where('name', 'Preliminary')->value('id')
  • MasterAssessmentStatus::whereIn(...)->pluck('id')
  • MasterNoteCategory::whereJsonContains(...)->get()

This is the M1 finding from the performance report. At 1.8M interactions, even 5 queries × 0.5ms each = ~25 CPU-minutes of pure DB time per 2 days.

4. Silent failure hides data loss

catch (Throwable $e) {
    logger()->error('Auto-save failed...');
}

The user never knows if autosave is failing. If it fails consistently (e.g., a validation edge case), the user thinks their work is saved but it isn't.

5. saveQuietly() skips observers

Intentional — avoids cascading side effects and activity logging on every keystroke. Correct for autosave. But means autosaved changes won't appear in activity logs until the user hits the explicit Save button.

Recommendations

Short-term (low effort)

  • Replace MasterAssessmentStatus::where('name', 'Draft')... with MasterAssessmentStatus::idByName('Draft') — requires the CachesIdByName trait from the performance branch (M1 work, Ryan G)
  • Add a user-facing indicator when autosave fails (e.g., a small warning toast or icon)

Medium-term (architectural)

  • Debounce across all fields — instead of saving per-field, use a single client-side timer that fires 2 seconds after the last change. This collapses 30 field changes into 1 autosave. Could be implemented with Alpine.js x-on:change.debounce.2000ms on the form wrapper, or a Livewire $debounce property.
  • This alone would reduce the 1.8M interaction volume significantly — most of those interactions are likely rapid sequential field changes during form fills.

Long-term (depends on performance branch merge)

  • M1 caching work: add CachesIdByName to all master models queried in form(), wrap repeated queries in once(). This reduces the per-autosave query cost from 5+ queries to 0 (served from cache).
  • Combine with debounce for the best result: fewer autosaves × cheaper autosaves.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment