Skip to main content
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Gert Franz
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Gert Franz ( @gert_railo )

Auto-Saving Form Data In The Background Using The fetch() API

By on

In Dig Deep Fitness, my ColdFusion fitness tracker, the main gesture of the app is the "Perform Exercise" view. In this view, the user is presented with a series of inputs for resistance weights, reps, and notes. Depending on how fast a user is moving through their workout, they may be on this one view for several minutes without submitting the form back to the ColdFusion server. This "pending data" makes me nervous. As such, I've started auto-saving the form data in the background using JavaScript's fetch() API.

Historically, I would have implemented this auto-save functionality by creating an API end-point and then POSTing the data to the API. However, I decided to take a page from the Hotwire Turbo playbook; and, simply POST the form data back to the same resource that I would with the normal form submission. This way, I don't (really) have to add any additional form-process logic.

Posting the form data via JavaScript is made almost effortless thanks to the FormData object. When invoking the FormData() constructor, we can supply an HTMLFormElement: new FormData( form ). And, in doing so, the browser will automatically populate the FormData instance with all of the valid form-fields that would have been submitted back to the server on a native form submission.

Here is my JavaScript method for executing this background-save operation using the FormData class and the fetch() API - assume that I have an existing form reference variable:

<script type="text/javascript">

	// ... truncated code ...

	// I submit the form data in the background.
	function commitAutoSave() {

		// CAUTION: While the form node has PROPERTIES for both "method" and "action",
		// Form elements have provide an historic behavior where you can reference
		// any input element by name. As such, you will commonly run into an issue
		// where in an input elements with the colliding names, "method" and "action"
		// (such as our Button). As such, it is a good practice to access the form
		// properties as the attributes.
		var formMethod = form.getAttribute( "method" );
		var formAction = form.getAttribute( "action" );
		// The FormData() constructor can be given a form element, and will
		// automatically populate the FormData() instance with all of the valid values
		// that would normally be submitted along with the native form submission.
		var formData = new FormData( form );

		fetch(
			formAction,
			{
				method: formMethod,
				body: formData
			}
		).catch(
			( transportError ) => {

				console.warn( "Fetch API failed to send." );
				console.error( transportError );

			}
		);

	}

</script>

There's very little going on here - I'm grabbing the form's method and action attributes, I'm collecting the form data via the FormData() constructor, and then I'm using the fetch() API to submit the form in the background. Since this is a background-save operation, I don't really care about the response, even if it's an error response. Ultimately, the background-save is a nice-to-have, progressive enhancement that may help to prevent data-loss; but, it's still the responsibility of the user to explicitly submit their form when they are done performing the exercise.

Of course, committing the background-save is only half the problem - the other half is figuring out when to perform the background-save. For that, I am going to listen for the input event on the form element. The input event is fired any time a change is made to the value of a form control. And, the input event bubbles-up in the DOM (Document Object Model). Which means, the form element acts as a natural event-delegation choke-point for all inputs contained therein.

Here's the full JavaScript for this demo (less the ColdFusion code). I've included some debouncing such that I'm not actually triggering a fetch() call after every key-stroke:

<script type="text/javascript">

	var form = document.querySelector( "form" );
	var autoSaveTimer = null;

	// The "input" event bubbles up in the DOM from every input change. As such, we
	// can think of this as a form of event-delegation wherein we only have to listen
	// to the one root element instead of each individual inputs.
	form.addEventListener( "input", prepareForAutoSave );
	// Since we're going to be debouncing the "input" event with a timer, we're going
	// to want to cancel any pending background-save timer when the form is explicitly
	// submitted by the user.
	form.addEventListener( "submit", cancelAutoSave );

	// ---
	// PUBLIC METHODS.
	// ---

	// I cancel any pending background-save timer.
	function cancelAutoSave() {

		clearTimeout( autoSaveTimer );

	}


	// I setup a pending background-save timer.
	function prepareForAutoSave() {

		cancelAutoSave();
		autoSaveTimer = setTimeout( commitAutoSave, 500 );

	}


	// I submit the form data in the background.
	function commitAutoSave() {

		// CAUTION: While the form node has PROPERTIES for both "method" and "action",
		// Form elements have provide an historic behavior where you can reference
		// any input element by name. As such, you will commonly run into an issue
		// where in an input elements with the colliding names, "method" and "action"
		// (such as our Button). As such, it is a good practice to access the form
		// properties as the attributes.
		var formMethod = form.getAttribute( "method" );
		var formAction = form.getAttribute( "action" );
		// The FormData() constructor can be given a form element, and will
		// automatically populate the FormData() instance with all of the valid values
		// that would normally be submitted along with the native form submission.
		var formData = new FormData( form );

		fetch(
			formAction,
			{
				method: formMethod,
				body: formData
			}
		).catch(
			( transportError ) => {

				console.warn( "Fetch API failed to send." );
				console.error( transportError );

			}
		);

	}

</script>

As you can see, we're just building on top of the previous code, this time adding some DOM-event handlers that pipe the user's interactions into the background auto-save workflow.

Typically, when a form is submitted back to the ColdFusion server, the server will process the data and then redirect the user to another page. In the case of the background-save, I don't want the redirect to take place as I'm not actually inspecting the response of the fetch() API call. As such, the redirect represents an unnecessary request / unnecessary load on the server.

To prevent the redirect from taking place on the server, I'm setting the default action on the form to be the background save implementation. This way, the "full processing" of the form (including the redirect) only happens when the user explicitly submits the form with a different action (ie, the action provided by the form-submission button).

Here's the full ColdFusion code for this demo - note that the form.action CFParam tag defaults to backgroundSave. And, only invokes the CFLocation tag if the form.action is some other value:

<cfscript>

	// Defaulting the in-memory data structure for the demo.
	param name="application.userData" type="struct" default={};
	param name="application.userData.name" type="string" default="";
	param name="application.userData.description" type="string" default="";

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	param name="form.name" type="string" default="";
	param name="form.description" type="string" default="";
	param name="form.submitted" type="boolean" default=false;
	// By default, we're going to assume the action is a background-save. This way, we
	// only perform the full save / redirect back to the homepage when the form is
	// submitted with the actual submit button.
	param name="form.action" type="string" default="backgroundSave";

	// Process the form submission.
	if ( form.submitted ) {

		application.userData = {
			name: form.name.trim(),
			description: form.description.trim()
		};

		if ( form.action != "backgroundSave" ) {

			location( url = "./index.cfm", addToken = false );

		}

	// Initialize the form parameters.
	} else {

		form.name = application.userData.name;
		form.description = application.userData.description;

	}

</cfscript>

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
</head>
<body>
	<cfoutput>

		<h1>
			User Profile
		</h1>

		<form method="post" action="./edit.cfm">
			<input type="hidden" name="submitted" value="true" />

			<p>
				<strong>Name:</strong><br />
				<input
					type="text"
					name="name"
					value="#encodeForHtmlAttribute( form.name )#"
					size="40"
				/>
			</p>
			<p>
				<strong>Bio:</strong><br />
				<input
					type="text"
					name="description"
					value="#encodeForHtmlAttribute( form.description )#"
					size="40"
				/>
			</p>
			<p>
				<!---
					NOTE: The Submit button has the name "action". The value associated
					with this button will only be included in the form POST if the user
					clicks on this button (or hits Enter in a field that precedes the
					button in DOM-order).
				--->
				<button type="submit" name="action" value="save">
					Save
				</button>
			</p>
			<p>
				<a href="./index.cfm">Back to home</a>
			</p>
		</form>

	</cfoutput>

	<script type="text/javascript">

		var form = document.querySelector( "form" );
		var autoSaveTimer = null;

		// The "input" event bubbles up in the DOM from every input change. As such, we
		// can think of this as a form of event-delegation wherein we only have to listen
		// to the one root element instead of each individual inputs.
		form.addEventListener( "input", prepareForAutoSave );
		// Since we're going to be debouncing the "input" event with a timer, we're going
		// to want to cancel any pending background-save timer when the form is explicitly
		// submitted by the user.
		form.addEventListener( "submit", cancelAutoSave );

		// ---
		// PUBLIC METHODS.
		// ---

		// I cancel any pending background-save timer.
		function cancelAutoSave() {

			clearTimeout( autoSaveTimer );

		}


		// I setup a pending background-save timer.
		function prepareForAutoSave() {

			cancelAutoSave();
			autoSaveTimer = setTimeout( commitAutoSave, 500 );

		}


		// I submit the form data in the background.
		function commitAutoSave() {

			// CAUTION: While the form node has PROPERTIES for both "method" and "action",
			// Form elements have provide an historic behavior where you can reference
			// any input element by name. As such, you will commonly run into an issue
			// where in an input elements with the colliding names, "method" and "action"
			// (such as our Button). As such, it is a good practice to access the form
			// properties as the attributes.
			var formMethod = form.getAttribute( "method" );
			var formAction = form.getAttribute( "action" );
			// The FormData() constructor can be given a form element, and will
			// automatically populate the FormData() instance with all of the valid values
			// that would normally be submitted along with the native form submission.
			var formData = new FormData( form );

			fetch(
				formAction,
				{
					method: formMethod,
					body: formData
				}
			).catch(
				( transportError ) => {

					console.warn( "Fetch API failed to send." );
					console.error( transportError );

				}
			);

		}

	</script>
</body>
</html>

If I now open this ColdFusion page and start typing, we can see that the background-save is triggering fetch() API calls while I type. These calls are incrementally updating the persisted user-data with my pending form submission:

Network activity demonstrating that fetch() API is triggering background-save as the user enters data into the form.

As you can see, whenever there is a brief pause in the data-entry, I'm triggering a background fetch() API request in order to persist the data to the ColdFusion server. This kind of a workflow doesn't make sense all the time. But, in this particular case - where I want to prevent possible data-loss - it's going to give me peace-of-mind.

Want to use code from this post? Check out the license.

Reader Comments

24 Comments

Was comparing fetch() to xhr() and jQuery.ajax() functions after seeing this. Fetch, unfortunately, doesn't send or receive any cookies (could be good OR bad), so you'd have to have an alternative session management if being logged in is required.

I think. I read it all really fast!

15,688 Comments

@Will,

The fetch() API will definitely post cookies along with the request. There may be a way to turn that off - I'm not sure. But, it certainly posts them by default.

And, to be clear, I'm not trying to say that fetch() is any better than jQuery.ajax() - or any other transport. It just happens to be native to the browser runtime now, so I was able to use it in a place where I didn't have any other library available to me.

Personally, I think the Developer experience of the fetch() is not great. Things like jQuery.ajax() feel more dev-friendly. Normally, when I use the fetch() API, I'll actually wrap it in something else so that I can hide-away the low-level bits that I don't really care about.

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel