How to Auto-Save a Form Draft and Restore It Later in Oracle APEX

Build server-side form drafts with a JSON draft table, a debounced dynamic action, a restore notice with Restore and Discard buttons, and a cleanup process.

Long forms get lost. A user types meeting notes for ten minutes, the browser crashes or the tab is closed by mistake, and everything is gone. The page never reached the database, so there is nothing to bring back.

An auto-saved draft fixes this. While the user types, the form quietly saves its values to a draft table on the server. When the user opens the form again, even on another day or another device, APEX offers to restore the draft or discard it. A successful save removes the draft.

This guide builds that in Oracle APEX with one table, a debounced dynamic action and a few lines of PL/SQL. It uses meeting notes as the example; the same steps work for any form.

The post How to Save Data in the Browser and Prompt App Installs Using apex.storage and apex.pwa keeps data in the browser with apex.storage. That data stays on one device and is lost when the browser storage is cleared. Here the draft lives in the database, belongs to the signed-in user, and needs no JavaScript code.

Step 1: Create the Tables

The example needs two tables. MEETING_NOTES is the table behind the form. FORM_DRAFTS holds one draft per user, application, page and record. The draft values are stored in a JSON column, so one draft table serves every form of the application.

Create the tables and the sample data:

Example:

create table meeting_notes (
  note_id       number generated by default on null as identity,
  title         varchar2(200)  not null,
  meeting_date  date           not null,
  attendees     varchar2(500),
  notes         varchar2(4000),
  created_by    varchar2(255)  default on null
                  coalesce(sys_context('APEX$SESSION', 'APP_USER'), user) not null,
  created_on    date           default on null sysdate not null,
  constraint meeting_notes_pk primary key (note_id)
);

create table form_drafts (
  draft_id      number generated by default on null as identity,
  app_user      varchar2(255)  not null,
  app_id        number         not null,
  page_id       number         not null,
  record_key    varchar2(100)  not null,
  draft_data    json           not null,
  saved_on      timestamp      default on null systimestamp not null,
  constraint form_drafts_pk primary key (draft_id),
  constraint form_drafts_uk unique (app_user, app_id, page_id, record_key)
);

insert into meeting_notes (title, meeting_date, attendees, notes, created_by)
values ('Sprint planning', date '2026-10-05', 'Anna, Raj, Mei',
        'Agreed on the scope of sprint 14. Raj takes the login changes.', 'DEMO_DEV');
insert into meeting_notes (title, meeting_date, attendees, notes, created_by)
values ('Supplier review', date '2026-10-08', 'Tom, Lisa',
        'Delivery times improved. Next review in December.', 'DEMO_DEV');
insert into meeting_notes (title, meeting_date, attendees, notes, created_by)
values ('Budget check', date '2026-10-12', 'Anna, Lisa, Omar',
        'Travel budget at 70 percent. No new requests this quarter.', 'SALES_MANAGER');
commit;

select note_id, title, to_char(meeting_date, 'YYYY-MM-DD') meeting_date,
       attendees, created_by
from   meeting_notes
order  by note_id;

Output:

Table MEETING_NOTES created.

Table FORM_DRAFTS created.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

   NOTE_ID TITLE              MEETING_DATE    ATTENDEES           CREATED_BY
__________ __________________ _______________ ___________________ ________________
         1 Sprint planning    2026-10-05      Anna, Raj, Mei      DEMO_DEV
         2 Supplier review    2026-10-08      Tom, Lisa           DEMO_DEV
         3 Budget check       2026-10-12      Anna, Lisa, Omar    SALES_MANAGER
  • RECORD_KEY is the primary key of the record being edited, or NEW for a record that does not exist yet.
  • The unique constraint FORM_DRAFTS_UK allows only one draft per user, application, page and record. A new auto-save replaces the old draft instead of adding rows.
  • DRAFT_DATA uses the JSON data type of Oracle Database 26ai, so it only accepts valid JSON.
  • CREATED_BY, CREATED_ON and SAVED_ON are filled by DEFAULT ON NULL, so no trigger is needed.

Step 2: Create the Report and Form Pages

In App Builder, open the application and click Create Page. On the Component tab, click Interactive Report:

Create a Page dialog with the Interactive Report page type marked with a red box
The Interactive Report page type.

Enter Page Number 410 and Name Meeting Notes. Turn on Include Form Page, enter Form Page Number 411 and Form Page Name Meeting Note. Keep Form Page Mode Normal, so the form opens as a full page in its own tab:

Create Interactive Report wizard with Page Number 410, Name Meeting Notes and the form page settings marked with red boxes
A report page and a normal form page.

Scroll down to Data Source. Keep Source Type Table and enter MEETING_NOTES as the Table / View Name. Keep the Navigation settings, so the report gets a menu entry, and click Next:

Data Source section of the wizard with Table / View Name MEETING_NOTES marked with a red box
The meeting notes table as the source.

The wizard reads the primary key and fills Primary Key Column 1 with NOTE_ID. Click Create Page:

Primary Key screen of the wizard with Primary Key Column 1 NOTE_ID marked with a red box
NOTE_ID is the primary key.

The wizard creates page 410 with the report and page 411 with the form.

Step 3: Remove the Created By and Created On Items

The wizard made a form item for every column. The two audit columns get their values from DEFAULT ON NULL, so users should not type them. Open page 411 in Page Designer. Right-click P411_CREATED_BY and choose Delete:

Page Designer with the context menu of P411_CREATED_BY and the Delete entry marked with a red box
The database fills the audit columns.

Delete P411_CREATED_ON the same way. The form now has P411_NOTE_ID (hidden), P411_TITLE, P411_MEETING_DATE, P411_ATTENDEES and P411_NOTES.

Step 4: Add the Unsaved Draft Region

This region tells the user that a draft exists. It is only rendered when there is one for the current user and record.

In the Rendering tree, right-click Body and choose Create Region. Set Name and Title to Unsaved Draft and Type to Static Content. Under Source, enter this text in HTML Code:

Example:

You have an unsaved draft of this form. Restore it to continue where you left off, or discard it.
Region properties with Name and Title Unsaved Draft, Type Static Content and the HTML Code text marked with red boxes
A static region with a short message.

Under Layout, set Sequence to 5, so the region appears above the form. Under Appearance, set Template to Alert. Its default template options show a warning icon:

Layout and Appearance groups with Sequence 5 and Template Alert marked with red boxes
The region comes first and looks like a warning.

Under Advanced, set HTML DOM ID to unsaved_draft. The dynamic actions use this ID later.

Under Server-side Condition, set Type to Rows returned and enter this SQL Query:

Example:

select 1
from   form_drafts
where  app_user   = :APP_USER
and    app_id     = :APP_ID
and    page_id    = :APP_PAGE_ID
and    record_key = nvl(:P411_NOTE_ID, 'NEW')
Advanced and Server-side Condition groups with HTML DOM ID unsaved_draft, Type Rows returned and the SQL Query marked with red boxes
Show the region only when a draft exists.

:APP_USER, :APP_ID and :APP_PAGE_ID are built-in substitution values, so the query works on any page without changes. The condition runs after the form's initialization process, so P411_NOTE_ID already holds the record being edited.

Step 5: Add the Restore and Discard Buttons

Right-click the Unsaved Draft region and choose Create Button. Set Button Name to RESTORE_DRAFT and Label to Restore Draft. Under Layout, set Slot to Create, which places the button on the right side of the alert:

Button properties with Button Name RESTORE_DRAFT, Label Restore Draft and Slot Create marked with red boxes
The Restore Draft button.

Under Appearance, turn on Hot. Under Behavior, set Action to Defined by Dynamic Action, because the button must not submit the page:

Appearance and Behavior groups with Hot on and Action Defined by Dynamic Action marked with red boxes
The button only fires a dynamic action.

Create a second button in the same region the same way: Button Name DISCARD_DRAFT, Label Discard Draft, Slot Create and Action Defined by Dynamic Action. Leave Hot off.

Step 6: Auto-Save the Draft While the User Types

Right-click P411_TITLE and choose Create Dynamic Action. Set Name to Auto-save draft. Under When, set Event to Custom, Custom Event to input change, and Item(s) to P411_TITLE,P411_MEETING_DATE,P411_ATTENDEES,P411_NOTES.

Under Execution, set Type to Debounce and Time to 2000 milliseconds. Under Client-side Condition, set Type to JavaScript expression and enter the expression below:

Example:

!$('#unsaved_draft').is(':visible')
Dynamic Action properties with Name, Execution Type Debounce and Time 2000, Event Custom with Custom Event input change, the items, and the client-side JavaScript expression marked with red boxes
Save two seconds after the user stops typing.
  • The input event fires on every key in a text field or text area. In a first test with the built-in Input event, picking a date from the date picker popup sent no request at all. The change event covers that case, so the custom event lists both.
  • Debounce waits until no event has fired for two seconds and then runs the actions once. In the test, typing the title and picking a date sent one request; typing the attendees and a short note sent one more.
  • The client-side condition stops auto-saving while the Unsaved Draft region is visible. Otherwise a single key pressed before the user chooses would overwrite the old draft.

Under True, select the Show action that APEX created. Set Action to Execute Server-side Code and enter this PL/SQL Code:

Example:

declare
  l_key  form_drafts.record_key%type := nvl(:P411_NOTE_ID, 'NEW');
  l_data json := json_object(
                   'P411_TITLE'        value :P411_TITLE,
                   'P411_MEETING_DATE' value :P411_MEETING_DATE,
                   'P411_ATTENDEES'    value :P411_ATTENDEES,
                   'P411_NOTES'        value :P411_NOTES
                   returning json);
begin
  merge into form_drafts d
  using (select :APP_USER app_user, :APP_ID app_id, :APP_PAGE_ID page_id,
                l_key record_key
         from   dual) s
  on (    d.app_user   = s.app_user
      and d.app_id     = s.app_id
      and d.page_id    = s.page_id
      and d.record_key = s.record_key)
  when matched then
    update set d.draft_data = l_data,
               d.saved_on   = systimestamp
  when not matched then
    insert (app_user, app_id, page_id, record_key, draft_data)
    values (s.app_user, s.app_id, s.page_id, s.record_key, l_data);
end;

Set Items to Submit to P411_NOTE_ID,P411_TITLE,P411_MEETING_DATE,P411_ATTENDEES,P411_NOTES:

Action properties with Action Execute Server-side Code, Language PL/SQL, the PL/SQL Code and Items to Submit marked with red boxes
The MERGE saves or replaces the draft.

JSON_OBJECT builds the draft from the submitted values, using the item names as keys. MERGE inserts the draft the first time and updates it after that. No JavaScript code and no Ajax Callback process are needed.

Step 7: Restore the Draft

Right-click the RESTORE_DRAFT button and choose Create Dynamic Action. Set Name to Restore draft. APEX fills in Event Click, Selection Type Button and Button RESTORE_DRAFT:

Dynamic Action properties with Name Restore draft and Event Click on button RESTORE_DRAFT marked with red boxes
Fires when the user clicks Restore Draft.

Select the Show action under True. Set Action to Execute Server-side Code and enter this PL/SQL Code:

Example:

select json_value(draft_data, '$.P411_TITLE'),
       json_value(draft_data, '$.P411_MEETING_DATE'),
       json_value(draft_data, '$.P411_ATTENDEES'),
       json_value(draft_data, '$.P411_NOTES')
into   :P411_TITLE,
       :P411_MEETING_DATE,
       :P411_ATTENDEES,
       :P411_NOTES
from   form_drafts
where  app_user   = :APP_USER
and    app_id     = :APP_ID
and    page_id    = :APP_PAGE_ID
and    record_key = nvl(:P411_NOTE_ID, 'NEW');

Set Items to Submit to P411_NOTE_ID and Items to Return to P411_TITLE,P411_MEETING_DATE,P411_ATTENDEES,P411_NOTES:

Action properties with Execute Server-side Code, the restore query, Items to Submit P411_NOTE_ID and Items to Return marked with red boxes
JSON_VALUE reads each item back from the draft.

Do not skip Items to Submit. The wizard creates the form items with Storage Per Request (Memory Only), so P411_NOTE_ID is not kept in session state. In a first test without it, Discard did nothing on an existing record: the Ajax call saw an empty P411_NOTE_ID and looked for the NEW draft.

Right-click Execute Server-side Code under True and choose Create Action. Set Action to Hide, Selection Type to Region and Region to Unsaved Draft. Turn off Fire on Initialization:

Hide action with Selection Type Region, Region Unsaved Draft and Fire on Initialization off marked with red boxes
Hide the notice after restoring.

Fire on Initialization is on by default for a new Hide action. In a first test, it hid the Unsaved Draft region as soon as the page loaded, so users never saw it.

Step 8: Discard the Draft

Right-click the DISCARD_DRAFT button and choose Create Dynamic Action. Set Name to Discard draft. Select the Show action under True, set Action to Confirm and enter the Message "Discard the saved draft? This cannot be undone.":

Confirm action with the Message text marked with red boxes
Ask before deleting the draft.

Right-click Confirm and choose Create Action. Set Action to Execute Server-side Code, enter this PL/SQL Code and set Items to Submit to P411_NOTE_ID:

Example:

delete from form_drafts
where  app_user   = :APP_USER
and    app_id     = :APP_ID
and    page_id    = :APP_PAGE_ID
and    record_key = nvl(:P411_NOTE_ID, 'NEW');
Execute Server-side Code action with the DELETE statement and Items to Submit P411_NOTE_ID marked with red boxes
Delete the draft of this user and record.

Add a third action below it: Hide, Selection Type Region, Region Unsaved Draft, with Fire on Initialization off, the same as in Step 7.

Step 9: Delete the Draft After a Successful Save

Click the Processing tab of Page Designer. Right-click Processes and choose Create Process. Set Name to Delete Draft and keep Type Execute Code. Enter this PL/SQL Code:

Example:

delete from form_drafts
where  app_user   = :APP_USER
and    app_id     = :APP_ID
and    page_id    = :APP_PAGE_ID
and    record_key = case when :REQUEST = 'CREATE' then 'NEW'
                         else :P411_NOTE_ID end;
Process Delete Draft below Process form Meeting Note with Name, Type Execute Code and the PL/SQL Code marked with red boxes
The process runs after the form process.

The new process gets Sequence 20, so it runs after Process form Meeting Note.

After an insert, the form process has already put the new ID into P411_NOTE_ID. That is why the code uses the request instead. A new record was drafted under the key NEW; for the SAVE and DELETE buttons, the key is the note ID. Save the page.

Step 10: Test the Draft

Sign in as DEMO_DEV and open Meeting Notes. The report shows the three sample notes:

Meeting Notes interactive report with three sample notes
The sample notes.

Click Create and fill in the form. Type a title, pick a date from the calendar, then type the attendees and the notes:

Meeting Note form with Title Release review, a date, attendees and notes filled in
The form has not been saved.

Now close the browser tab without clicking Create. The form was never submitted, but the draft is in the table:

Example:

select app_user, page_id, record_key,
       to_char(saved_on, 'HH24:MI:SS') saved_at
from   form_drafts;

select json_serialize(draft_data pretty) draft_data
from   form_drafts
where  app_user   = 'DEMO_DEV'
and    page_id    = 411
and    record_key = 'NEW';

Output:

APP_USER       PAGE_ID RECORD_KEY    SAVED_AT
___________ __________ _____________ ___________
DEMO_DEV           411 NEW           01:21:10

DRAFT_DATA
________________________________________________________________________________________________________________________
{
  "P411_TITLE" : "Release review",
  "P411_MEETING_DATE" : "10/16/2026",
  "P411_ATTENDEES" : "Anna, Raj, Lisa",
  "P411_NOTES" : "Version 2.4 ships on 30 October. Lisa updates the release notes. Open bug 118 must be fixed first."
}

Sign in again and click Create. The Unsaved Draft notice appears above the empty form:

Meeting Note page with the Unsaved Draft alert, Restore Draft and Discard Draft buttons, marked with a red box above the empty form
APEX found the draft of this user.

As a check, a key was typed into the title while the notice was visible. No request was sent, so the draft stayed intact.

Click Restore Draft. The notice disappears and every field is back, including the date:

Meeting Note form with all restored values: Release review, 10/16/2026, attendees and notes
The draft is restored.

Setting the items fires their change events, so the auto-save ran once more after the restore and saved the same values again. That is harmless.

Click Create. The note is saved, and the report shows it:

Meeting Notes report with the new note Release review and the Row created message
The note is saved.

Click Create once more. The form is empty and the notice does not appear, because the Delete Draft process removed the draft.

Step 11: Test Drafts of Existing Records

Drafts also work when editing. Open Supplier review, add ", Omar" to the attendees and close the tab. Open Sprint planning: no notice appears, because its record key is 1. Open Supplier review again: the notice appears.

Click Restore Draft. The attendees field shows the unsaved change, while the other fields keep their values:

Meeting Note form for Supplier review with the restored attendees Tom, Lisa, Omar and the Apply Changes button
The draft of record 2 is restored.

Click Apply Changes. The attendees are saved, and the Delete Draft process removes the draft with record key 2. No drafts are left:

Example:

select note_id, title, attendees, created_by
from   meeting_notes
order  by note_id;

select count(*) open_drafts
from   form_drafts;

Output:

   NOTE_ID TITLE              ATTENDEES           CREATED_BY
__________ __________________ ___________________ ________________
         1 Sprint planning    Anna, Raj, Mei      DEMO_DEV
         2 Supplier review    Tom, Lisa, Omar     DEMO_DEV
         3 Budget check       Anna, Lisa, Omar    SALES_MANAGER
        21 Release review     Anna, Raj, Lisa     DEMO_DEV

   OPEN_DRAFTS
______________
             0

The note from Step 10 got ID 21, not 4, because the identity column hands out cached values. That is normal.

To test Discard Draft, edit Supplier review again, change the notes and close the tab. Open the note and click Discard Draft. The confirm dialog appears:

Meeting Note page with the confirm dialog Discard the saved draft? This cannot be undone. marked with a red box
The user confirms the discard.

Click OK. The notice disappears, the fields keep the saved values, and reopening the note shows no notice.

Step 12: Test With a Second User

Drafts belong to the user who typed them. As DEMO_DEV, click Create, type the title Q4 planning and leave the page. Then sign in as SALES_MANAGER in another browser and click Create. The form is empty and no notice appears:

Empty Meeting Note form for SALES_MANAGER without the Unsaved Draft notice
SALES_MANAGER does not see the draft of DEMO_DEV.

The table holds only the draft of DEMO_DEV:

Example:

select app_user, page_id, record_key,
       json_value(draft_data, '$.P411_TITLE') title
from   form_drafts
order  by app_user;

Output:

APP_USER       PAGE_ID RECORD_KEY    TITLE
___________ __________ _____________ ______________
DEMO_DEV           411 NEW           Q4 planning

Every draft query in this guide filters on :APP_USER, which APEX sets on the server. So one user's drafts are never shown to, restored for or deleted by another user.

Things to Know

  • Drafts that nobody restores stay in the table. Remove old ones on a schedule, for example with an automation that runs the DELETE below. In this example it deleted 0 rows, as all drafts were new.
  • Warn on Unsaved Changes stays on for the form page. The browser still asks before leaving the page; the draft covers crashes and tabs closed anyway.
  • Do not put passwords or card numbers in a draft. They would be stored as plain JSON.
  • To add drafts to another form, copy the region, buttons, dynamic actions and process, and change the item names. The FORM_DRAFTS table stays the same.

Example:

delete from form_drafts
where  saved_on < systimestamp - interval '30' day;

Output:

0 rows deleted.

Related Guides

Conclusion

An auto-saved form draft in Oracle APEX needs one draft table, one region and three dynamic actions. A debounced dynamic action saves the item values as JSON while the user types, and a Rows returned condition shows the restore notice when the form is opened again.

Restore and Discard work through Execute Server-side Code with Items to Submit and Items to Return, and a process deletes the draft after a successful save. Remember the two traps from the tests: submit the primary key item, and turn off Fire on Initialization for the Hide actions.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted