We’ve recently overhauled the Notes architecture to support a robust Autosave system. This update moves us away from manual saves to a reactive model, improving data persistence and user experience.
Here is a deep dive into the technical implementation, backend logic, and frontend state management.
System Behavior & Design Decisions
To ensure data integrity without overloading our API, we implemented the following constraints:
- Lazy Creation: A note is only created in the database when the first attachment is uploaded. If a user deletes all attachments before adding text, the empty note is automatically garbage collected.
- Intelligent Debouncing: Text changes trigger a save only after 1 second of inactivity. This minimizes database writes while keeping the UI responsive.
- Visual Feedback: A dedicated state machine drives the UI indicators (
Saving...→Saved ✓→Error), providing real-time feedback on the persistence layer.
Backend Implementation (Go)
We introduced a new atomic "Upsert" pattern to handle both creation and updates in a single endpoint.
1. API Handler (handler.go) We added a PUT /notes/autosave endpoint. Unlike standard updates, this endpoint relaxes validation rules to allow empty text content (essential for attachment-only notes).
Go
// New endpoint: PUT /notes/autosave?personId=...¬eId=...
// Logic:
// 1. If noteId exists -> Update
// 2. If noteId missing but provided in query -> Create with that ID
// 3. Allow empty note.text
2. Database Layer (fellow.go) We leveraged PostgreSQL's ON CONFLICT clause to handle race conditions during the upsert process.
Go
func UpsertNote(ctx context.Context, userID, noteID uuid.UUID, personID uuid.UUID, params types.Note) (*types.Note, error) {
// Implements atomic upsert logic
}
Frontend Implementation (React/TypeScript)
The frontend logic was refactored to decouple the saving mechanism from the UI components using a custom hook.
1. Custom Hook: useAutosave.ts This hook manages the save lifecycle, handling debouncing, retries, and stale state prevention.
TypeScript
interface AutosaveState {
status: 'idle' | 'saving' | 'saved' | 'error';
lastSaved: Date | null;
error: string | null;
}
// Logic includes 1s debounce and transient failure retry
function useAutosave(...) : AutosaveState;
2. Component Integration (NoteForm.tsx) We removed the hard dependency on the "Save" button. The form now listens for changes in content or date and triggers the hook.
- Auto-create: Fires immediately on
onFirstUploadComplete. - Cleanup: On close, if
contentis empty andattachmentsare 0, the note is deleted.
State Flow Architecture
The system transitions through the following states during a user session:
- Empty → NoteCreated (via Upload or Typing)
- NoteCreated → Saving (Debounced change)
- Saving → Saved (API 200 OK) OR Error (Network Fail)
- Error → Saving (Retry logic)
Verification & Testing
Since this flow relies heavily on timing, we validated it against the following scenarios:
- Network Throttling: Verified "Save failed" states by taking the network offline in DevTools.
- Race Conditions: Confirmed that rapid typing resets the debounce timer correctly (checking Network tab for cancelled/delayed requests).
- Orphaned Data: Verified that closing a form with 0 attachments and 0 text successfully triggers the delete cleanup.