Skip to main content

Auto-Save Draft Feature in Oracle APEX Forms

Auto-Save Draft Feature in Oracle APEX Forms

Silently save form data every 30 seconds - no user action needed.

Ever filled a long form and lost everything because the session timed out or you accidentally closed the tab? I've seen users completely redo a 10-field form just because of a timeout. That's when I decided to build a proper auto-save draft - one that works silently, without the user doing anything.

Wait - Doesn't APEX Already Handle This?

Yes, and this was my first thought too. When you open any page item in APEX and look at its properties, there's a Storage setting with three options. Let me walk through what each one actually does, because understanding this is exactly why we're building something custom.

Per Request (Memory Only) - This is the default for most items. The value exists only during the current server request, and once that's done, APEX forgets it. Now, this is actually fine for normal form submits - when a user clicks the Submit button, APEX does a full page submit and all item values are sent to the server together, so session state gets updated correctly. The problem is with AJAX calls. When you call apex.server.process() in JavaScript, that's a background request - APEX doesn't automatically include item values in it. If you need an item's value on the server side during an AJAX call, you have to explicitly list it in "Page Items to Submit". So Per Request works fine for regular submits, but the moment you go async, you're on your own. For our auto-save feature which is entirely AJAX-based, this default simply doesn't work.

Per Session (Persistent) - This saves the item value in the APEX session state table in the database, tied to the user's current session ID. So if the user navigates away and comes back within the same session, the value is still there. Sounds useful - but sessions expire. Once the session times out (typically 8 hours of inactivity by default), everything is gone. Also, there's no concept of "this is a draft" vs "this is already submitted". The values just sit there, and the next time the user opens the form, APEX restores them automatically - even if that form was already submitted successfully last week. That's the real problem.

Per User (Persistent) - This goes one step further. Values are stored against the username itself, not the session. So even if the session expires, even if the user logs out and comes back days later on a different device, the values are restored. On paper this sounds like exactly what we need. But here's the catch: you have absolutely no control over when those values get cleared. After a successful form submission, APEX doesn't know to wipe them. The user submits the form, comes back tomorrow to fill a new entry, and APEX silently restores all the old values into the fields - without telling the user. No "draft restored" message, no way to discard, no audit trail. That's a UX nightmare waiting to happen.

๐Ÿ’ฌ
I actually tried Per User persistent storage on the first version of this feature. It worked fine until a user complained that their form "auto-filled with someone else's old data." It wasn't someone else's - it was their own from a previously submitted record. But from their perspective, something was wrong. That's when I switched to the custom table approach.

So Why a Custom Table?

With a custom apex_form_drafts table, we control everything. We decide when the draft is saved, when it's restored, and - most importantly - when it's deleted. After a successful submit, we explicitly delete the draft. Next time the user opens the form, it's clean. We also get to show the user a "Your previous draft has been restored" message, which Per User session state can never do because APEX restores those values silently before the page even loads.

On top of that, the custom table is reusable across every form in your entire application - just one table, differentiated by app_id and page_id. And if a user ever reports "I lost my data," you can actually query the table and check. With session state, there's nothing to debug.

That's the full picture. Now let's build it.


1 Create the Draft Table

One table for your entire application. The UNIQUE constraint on (app_id, page_id, app_user) means one active draft per user per page - no duplicates, no mess.

SQL - Create Table
CREATE TABLE apex_form_drafts (
  draft_id    NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  app_id      NUMBER         NOT NULL,
  page_id     NUMBER         NOT NULL,
  app_user    VARCHAR2(255)  NOT NULL,
  draft_data  CLOB,
  created_on  TIMESTAMP DEFAULT SYSTIMESTAMP,
  updated_on  TIMESTAMP DEFAULT SYSTIMESTAMP,
  CONSTRAINT uq_draft UNIQUE (app_id, page_id, app_user)
);

CREATE INDEX idx_draft_user
  ON apex_form_drafts(app_id, page_id, app_user);

2 Page Items Setup

Set up your form page (Page 29 in my case) with these items. The important one is P8_LAST_SAVED - it's Display Only and shows "Draft auto-saved at HH:MM:SS". No DB column needed for it.

Item NameType
P8_FULL_NAMEText Field
P8_EMAILText Field
P8_PHONE_NUMBERText Field
P8_DEPARTMENTSelect List
P8_NOTESTextarea
P8_LAST_SAVEDDisplay Only - no DB column

3 App Process - SAVE_DRAFT

Create an Application Process (not page process) with Point set to Ajax Callback. It receives the draft JSON via g_x01, uses MERGE to insert or update, then returns a timestamp back to JavaScript.

PL/SQL - SAVE_DRAFT
DECLARE
  l_data  CLOB := apex_application.g_x01;
BEGIN
  MERGE INTO apex_form_drafts d
  USING DUAL
  ON (
    d.app_id   = :APP_ID
    AND d.page_id  = :APP_PAGE_ID
    AND d.app_user = :APP_USER
  )
  WHEN MATCHED THEN
    UPDATE SET
      draft_data = l_data,
      updated_on = SYSTIMESTAMP
  WHEN NOT MATCHED THEN
    INSERT (app_id, page_id, app_user, draft_data)
    VALUES (:APP_ID, :APP_PAGE_ID, :APP_USER, l_data);

  COMMIT;

  apex_json.open_object;
  apex_json.write('status',  'saved');
  apex_json.write('savedAt', TO_CHAR(SYSTIMESTAMP, 'HH24:MI:SS'));
  apex_json.close_object;
END;

4 App Process - LOAD_DRAFT

Same setup - Ajax Callback. Called on page load to check if a draft exists for this user. Returns hasDraft: true with the data, or false if nothing found.

PL/SQL - LOAD_DRAFT
DECLARE
  l_data  CLOB;
BEGIN
  BEGIN
    SELECT draft_data INTO l_data
    FROM   apex_form_drafts
    WHERE  app_id   = :APP_ID
    AND    page_id  = :APP_PAGE_ID
    AND    app_user = :APP_USER;
  EXCEPTION
    WHEN NO_DATA_FOUND THEN l_data := NULL;
  END;

  apex_json.open_object;
  IF l_data IS NOT NULL THEN
    apex_json.write('hasDraft', TRUE);
    apex_json.write('data', l_data);
  ELSE
    apex_json.write('hasDraft', FALSE);
  END IF;
  apex_json.close_object;
END;

5 App Process - CLEAR_DRAFT

Deletes the draft row. Called when the user clicks "Discard Draft" or after a successful form submit.

PL/SQL - CLEAR_DRAFT
BEGIN
  DELETE FROM apex_form_drafts
  WHERE  app_id   = :APP_ID
  AND    page_id  = :APP_PAGE_ID
  AND    app_user = :APP_USER;
  COMMIT;

  apex_json.open_object;
  apex_json.write('status', 'cleared');
  apex_json.close_object;
END;

6 Dynamic Action - Auto-Save Timer

Create a Dynamic Action: Event → Page Load, Fire on Init → Yes. This sets a 30-second interval that collects all field values and sends them to SAVE_DRAFT. The interval ID is saved on window so we can cancel it later on submit.

JavaScript - Auto-Save Timer
var pageItems = [
  'P8_FULL_NAME', 'P8_EMAIL',
  'P8_PHONE_NUMBER', 'P8_DEPARTMENT', 'P8_NOTES'
];

function collectDraftData() {
  var draft = {};
  pageItems.forEach(function(id) {
    draft[id] = apex.item(id).getValue();
  });
  return JSON.stringify(draft);
}

function saveDraft() {
  apex.server.process('SAVE_DRAFT', { x01: collectDraftData() }, {
    dataType: 'json',
    success: function(data) {
      if (data.status === 'saved') {
        apex.item('P8_LAST_SAVED').setValue('Draft auto-saved at ' + data.savedAt);
      }
    },
    error: function() {
      console.warn('Auto-save failed. Will retry next interval.');
    }
  });
}

// Save every 30 seconds
window._autoSaveInterval = setInterval(saveDraft, 30000);

7 Dynamic Action - Restore Draft on Page Load

A second Dynamic Action on Page Load. It calls LOAD_DRAFT and checks if a draft exists. If one does, it loops through each field and sets the value - but it only shows the "Your previous draft has been restored" message if at least one field actually had data. This matters because the draft row can exist in the table but all fields might be empty (if the user opened the form, waited, and never typed anything). Showing a success message in that case would just confuse people.

JavaScript - Restore Draft
apex.server.process('LOAD_DRAFT', {}, {
  dataType: 'json',
  success: function(data) {
    if (!data.hasDraft || !data.data) { return; }

    var draft = JSON.parse(data.data);
    var restored = false;

    Object.keys(draft).forEach(function(id) {
      if (apex.item(id) && draft[id] !== '' && draft[id] !== null) {
        apex.item(id).setValue(draft[id]);
        restored = true;
      }
    });

    // Only show the message if at least one field had actual data
    if (restored) {
      apex.message.showPageSuccess('Your previous draft has been restored.');
    }
  }
});

8 Discard Draft Button

Button settings: Action → Defined by Dynamic Action, Style → Danger. The Dynamic Action on click shows a confirmation, calls CLEAR_DRAFT, clears all fields, and stops the timer.

JavaScript - Discard Draft
apex.confirm('Discard your saved draft? This cannot be undone.', {
  title: 'Discard Draft',
  okLabel: 'Yes, Discard',
  cancelLabel: 'Cancel',
  callback: function(confirmed) {
    if (confirmed) {
      apex.server.process('CLEAR_DRAFT', {}, {
        dataType: 'json',
        success: function() {
          ['P8_FULL_NAME','P8_EMAIL','P8_PHONE_NUMBER',
           'P8_DEPARTMENT','P8_NOTES','P8_LAST_SAVED']
            .forEach(function(id) { apex.item(id).setValue(''); });
          apex.message.showPageSuccess('Draft discarded.');
          clearInterval(window._autoSaveInterval);
        }
      });
    }
  }
});

9 Clear Draft After Final Submit

Add a Page Process on your submit page - Type: Execute Code, Point: Processing - with a higher sequence than your main save process. This runs after the form data is saved and deletes the draft so users don't see a stale restore message next time.

PL/SQL - After Submit Page Process
BEGIN
  DELETE FROM apex_form_drafts
  WHERE  app_id   = :APP_ID
  AND    page_id  = :APP_PAGE_ID
  AND    app_user = :APP_USER;
  COMMIT;
END;

10 Optional: Cleanup Old Drafts

Without this, the table slowly fills up with drafts from users who never came back. This scheduler job runs at 2 AM daily and purges anything older than 7 days - totally set and forget.

PL/SQL - DBMS_SCHEDULER Job
BEGIN
  DBMS_SCHEDULER.CREATE_JOB(
    job_name        => 'CLEANUP_OLD_DRAFTS',
    job_type        => 'PLSQL_BLOCK',
    job_action      => '
      BEGIN
        DELETE FROM apex_form_drafts
        WHERE updated_on < SYSTIMESTAMP - INTERVAL ''7'' DAY;
        COMMIT;
      END;',
    start_date      => SYSTIMESTAMP,
    repeat_interval => 'FREQ=DAILY;BYHOUR=2;BYMINUTE=0',
    enabled         => TRUE,
    comments        => 'Remove APEX form drafts older than 7 days'
  );
END;

That's It!

Ten steps and your forms now silently protect everything your users type. Once you set this up the first time, reusing it across other pages takes maybe 10 minutes - just point it at a different page and update the item names.

The "draft restored" message is honestly the detail users appreciate most. It tells them the system is paying attention. Small touch, big impact.

Full source code is on GitHub and you can try it live via the demo link. Drop any questions in the comments - happy to help you adapt this to your own setup.

Comments

Popular posts from this blog

Generate Custom PDFs in Oracle APEX with jsPDF

Generate Custom PDFs in Oracle APEX with jsPDF A complete step-by-step guide to building a client-side PDF generator with profile image embedding and PL/SQL database persistence. &#128196; Step-by-step guide Oracle APEX jsPDF Dynamic Actions PL/SQL JavaScript "Picture this: your Oracle APEX application is polished, your users love it — but when they ask for a downloadable report with a profile picture and custom styling, you realize vanilla APEX isn't built for that out of the box. What if a single JavaScript library and one Dynamic Action could change everything?" Welcome to the world of jsPDF inside Oracle APEX . In this tutorial, we wire up a PDF generator that captures a user's name, email, phone number, and profile picture — renders them into a beautiful PDF layout — and saves everything to Oracle, all in one button click. ⓘ What you'll build: A dynamic PDF generat...

APEX Custom Auth Settings, Decoded!

Oracle APEX Security PL/SQL APEX Custom Auth Settings, Decoded Why I Build this I was building an internal APEX app — strictly for people on our corporate network. The default authentication worked, but it had a few things that kept bothering me. Anyone with valid credentials could log in from anywhere. Sessions expired with a useless error page. There was no audit trail — no way to know who logged in, when, or from where. And passwords were stored in plain text, which I just couldn't leave alone. So I did what any developer does — I built it myself. Custom authentication, from scratch, in PL/SQL. Turned out to be one of the best learning experiences I've had with APEX. Here's exactly how I did it. Img 1 : Head to Shared Components → Authentication Schemes → Create, choose Custom as the Scheme Type, and plug in your function and procedure names. Quick note before we get into the code — the s...

APEX 26.1 is here - and there's a lot to talk about

Oracle APEX APEX 26.1 is here - and there's a lot to talk about ๐Ÿš€ A quick look at everything that came in this release - big and small. May 2026  ·  90+ community ideas shipped When APEX 26.1 dropped, I honestly wasn't expecting this much. I opened the release notes thinking I'd be done in 10 minutes. An hour later, I was still reading - going "wait, they added this too?" This post is not a deep dive into any single feature. It's just me walking you through what's new in 26.1, so you know what to look forward to. We'll cover each feature properly in separate posts. But first, let's get the full picture. I also personally tried some of the community-submitted features in this release, and I'll share my honest experience with each one. Some of them are small things - but small things are what make daily dev life better. You'll see what I mean. ๐Ÿ˜„ New 1. APEXlang - Your App Des...