Every successful web application inevitably gets more users, and in that crowd of users, there is an individual who enthusiastically carries the torch of desktop apps in their heart. These torchbearers of the desktop have in their fingers the impulsive need to double-click every button. OS designers built Double-clicks into the UX language of all desktop operating systems. Still, the double-click can wreak havoc on unsuspecting web developers who assume all users will click once to submit forms.

This post will show a straightforward server-side technique to reduce, if not eliminate, a user’s duplicate requests while still keeping everyone, including us, happy in the process.

Let’s Talk About Idempotency

Originating from mathematics, the term idempotent is jargon for saying, “we can apply this operation multiple times and still receive the same outcome”.

We have described a situation where a user may be double-clicking a form’s submit button, initiating multiple identical web requests. Exact requests can have harmless side-effects like sending a duplicate communication or can cause the stressful situation of withdrawing funds numerous times from an individual’s bank account.

The HTTP protocol has idempotency methods built into the specification, with GET, OPTIONS, and HEAD all generally used for reading operations. Non-idempotent methods include POST, PUT, PATCH, and DELETE. The later methods tend to mutate state, whether creating a new resource, updating an existing one, or deleting it entirely.

So how do we make these non-idempotent methods idempotent-like?

Making Calls Idempotent (In Theory)

We need to send an Idempotency token with every request that can cause a state change. Since we’re dealing with web applications, each form will generate a globally unique token when the browser loads the UI. This token will then be sent to our server and saved to a storage mechanism. As requests come in, we will verify the token against the storage mechanism to ensure we haven’t seen it before.

Ok, so how do we do that?

Implementing ASP.NET Core Idempotency By Resource

The first approach is to build idempotency into our resources. In this case, we’ll be using Entity Framework Core and the unique constraint available on an index. Let’s take a look at our Message that we should only process once.

[Index(nameof(IdempotentToken), IsUnique = true)]
public class Message
{
    public int Id { get; set; }
    public string Text { get; set; }
    
    public string IdempotentToken { get; set; }
}

We get to take advantage of the transactional features of our database engine. Using a database reduces our need to manage in-memory storage structures. Storing the idempotent token alongside our resource will also allow us to implement explicit logic for each resource-based scenario. We’ll see a general solution later but loses some error-handling opportunities.

We’ll be using Razor Pages, but we could apply this approach to MVC as well.

using System;
using System.Text.Json;
using System.Threading.Tasks;
using ContactForm.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace ContactForm.Pages
{
    public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> logger;
        private readonly Database database;

        [BindProperty] public string IdempotentToken { get; set; }
        [BindProperty] public string Text { get; set; }

        [TempData] public string AlertCookie { get; set; }

        public Alert Alert =>
            AlertCookie is not null
                ? JsonSerializer.Deserialize<Alert>(AlertCookie)
                : null;

        public IndexModel(ILogger<IndexModel> logger, Database database)
        {
            this.logger = logger;
            this.database = database;
        }

        public void OnGet()
        {
            IdempotentToken = Guid.NewGuid().ToString();
        }

        public async Task<IActionResult> OnPost()
        {
            try
            {
                if (string.IsNullOrEmpty(IdempotentToken))
                {
                    AlertCookie = Alert.Error.ToJson();
                    return Page();
                }

                database.Messages.Add(new Message
                {
                    IdempotentToken = IdempotentToken,
                    Text = Text
                });

                // will throw if unique
                // constraint is violated
                await database.SaveChangesAsync();

                TempData[nameof(AlertCookie)] =
                    new Alert("Successfully received message").ToJson();

                // perform Redirect -> Get
                return RedirectToPage();
            }
            catch (DbUpdateException ex)
                when (ex.InnerException is SqliteException {SqliteErrorCode: 19})
            {
                AlertCookie = new Alert(
                    "You somehow sent this message multiple time. " +
                        "Don't worry its safe, you can carry on.", 
                    "warning")
                .ToJson();
            }
            catch
            {
                AlertCookie = Alert.Error.ToJson();
            }

            return Page();
        }
    }

    public record Alert(string Text, string CssClass = "success")
    {
        public string ToJson()
        {
            return JsonSerializer.Serialize(this);
        }

        public static Alert Error { get; } = new(
            "We're not sure what happened.",
            "warning"
        );
    };
}

Let’s go through the code step-by-step:

  1. In the OnGet method, we generate a unique token using Guid, but this could be any unique value. We will use the value in our HTML form.
  2. In our OnPost method, we attempt to save the IdempotentToken along with our Text value. If it is the first time we see the request, we will store it with no issues.
  3. If we have already stored the token, we will get a DbUpdateException with an inner exception of SqliteException. The exception will depend on our database engine choice.

In our HTML, we need to make sure that the token is part of our form post.

<form method="post" asp-page="Index">
    <div class="form-group">
        <label asp-for="Text"></label>
        <textarea class="form-control" asp-for="Text" rows="3"></textarea>
    </div>
    <input asp-for="IdempotentToken" type="hidden" />
    <button type="submit" class="btn btn-primary mb-2">Send Message</button>
</form>

Let’s look at the three situations that can occur during a user’s experience on our site—starting with a successful request.

successful web request using current idempotent token

Looks good; what happens when we reuse a token?

unsuccessful web request when not idempotent token is present

Finally, what happens when we don’t include a token at all?

web request using current idempotent token

Great! Seems to be working, but what about a more general solution?

Idempotency Using ASP.NET Core Middleware

Since we’re still in ASP.NET Core, our other general option to use middleware to inspect POST requests for a generic token entity in our database to shortcircuit incoming duplicate requests. First, we’ll create a storage mechanism for tokens using Entity Framework Core.

[Index(nameof(IdempotentToken), IsUnique = true)]
public class Requests
{
    public int Id { get; set; }
    public string IdempotentToken { get; set; }

    public static string New()
    {
        return Guid.NewGuid().ToString();
    }
}

Then we’ll need to implement a StopDuplicatesMiddleware middleware.

using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ContactForm.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace ContactForm.Pages
{
    public class StopDuplicatesMiddleware : IMiddleware
    {
        private readonly string key;
        private readonly string alertTempDataKey;

        public StopDuplicatesMiddleware(string key = "IdempotentToken", string alertTempDataKey = "AlertCookie")
        {
            this.key = key;
            this.alertTempDataKey = alertTempDataKey;
        }

        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            if (
                context.Request.Method == HttpMethod.Post.Method &&
                context.Request.Form.TryGetValue(key, out var values))
            {
                var token = values.FirstOrDefault();
                var database = context.RequestServices.GetRequiredService<Database>();
                var factory = context.RequestServices.GetRequiredService<ITempDataDictionaryFactory>();
                var tempData = factory.GetTempData(context);

                try
                {
                    database.Requests.Add(new Requests
                    {
                        IdempotentToken = token
                    });
                    // we're good
                    await database.SaveChangesAsync();
                }
                catch (DbUpdateException ex)
                    when (ex.InnerException is SqliteException {SqliteErrorCode: 19})
                {
                    tempData[alertTempDataKey] = new Alert(
                            "You somehow sent this message multiple time. " +
                            "Don't worry its safe, you can carry on.",
                            "warning")
                        .ToJson();
                    tempData.Keep(alertTempDataKey);
                    
                    // a redirect and
                    // not an immediate view
                    context.Response.Redirect("/", false);
                }
            }
            
            await next(context);
        }
    }
}

The middleware we wrote will store any token it receives in the database. If the token already exists, the call will throw an exception. In this case, we’ll keep an alert in our TempData, which the Razor Page will use in our redirect.

As a final step, we’ll need to register our StopDuplicatesMiddleware with the ASP.NET Core pipeline.

using ContactForm.Models;
using ContactForm.Pages;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ContactForm
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddEntityFrameworkSqlite();
            services.AddDbContext<Database>();
            services.AddSingleton<StopDuplicatesMiddleware>();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseAuthorization();
            
            app.UseMiddleware<StopDuplicatesMiddleware>();

            app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); });
        }
    }
}

With the middleware registered, we get the same behavior across all forms with an input element with the name IdempotentToken.

Conclusion

It’s always best practice to write your applications to account for idempotency. This post saw two approaches with Entity Framework Core and ASP.NET Core to reduce the chances of processing the same request twice. The code here is a starting point, but anyone can adapt it to their specific needs and technology stack.

There are a few downsides to this approach that developers should consider:

  • Our database engine is a potential bottleneck and might slow the overall experience down.
  • We are passing additional data to the server, which can affect performance.
  • Generating unique tokens at scale can become expensive and need to be managed.

A more straightforward approach for some folks might be to generate a placeholder for the resource and then use subsequent requests to hydrate and complete the entity. Examples of preemptively creating a resource include shopping carts or completing an online survey. This modified approach eliminates the need for multiple data storage mechanisms and unique constraints.

To download this solution, you can head over to my GitHub repository and try it out yourself.

So, how do you limit duplicate requests in your system? Is idempotency vital to you, or is it just a part of running your web application? Let me know in the comments.