CodeKits.shopNew developer offer

Make download limits safe under concurrent requests

Use atomic database claims for limited downloads, define whether attempts or sessions count, and handle retries and ranges without overselling delivery.

By Jonathan · 4 min read

Published

Claim allowance, Commit decision, and Stream attempt
In this article

“Only three downloads” is ambiguous. Three HTTP requests, three completed transfers and three people saving a file are different things. The server can reliably limit grants or transfer attempts; it cannot prove that a person kept a copy.

This tutorial chooses a simple policy: one accepted full-file GET consumes one allowance, even if the connection later fails. It targets MySQL 8.4 InnoDB and a Next.js server-side download handler. The database snippets are integration examples, not a tested running service; use the two-connection verification below before adopting them.

Make the allowance a database decision

Start with a link table containing a unique token hash, immutable file ID, expiry, optional revocation timestamp and non-negative remaining count. Account-bound links also include the intended account ID. Validate authentication and file access before attempting a claim.

A separate SELECT followed by UPDATE is unsafe. Both workers can read “one remaining” before either changes it. Instead, use a conditional mutation:

claim.sql — bind token hash and verified account ID
UPDATE download_links
SET remaining = remaining - 1
WHERE token_hash = ?
  AND account_id = ?
  AND remaining > 0
  AND revoked_at IS NULL
  AND expires_at > UTC_TIMESTAMP(6);

Require exactly one affected row. Zero means no allowance was granted. With an indexed unique token hash and InnoDB, competing updates serialize their changes to that record. Every successful decrement genuinely changes a value, avoiding ambiguity between matched and changed rows. MySQL UPDATE reference.

If you must fetch associated metadata and create a grant record consistently, do those operations on one connection inside a short transaction. Commit the grant before streaming; do not hold a row lock for the duration of a large download.

Decide the range policy explicitly

A download manager may send many range requests for one file. Counting each request as another human download makes the limit surprising. This first implementation does not support ranges: reject range requests before claiming an allowance and state that resumable downloads are unavailable.

A more capable design consumes one allowance when creating an expiring transfer session. Subsequent range requests must authenticate that session and reference the same immutable object version. Limit session duration, concurrent ranges and bytes served to reduce abuse. The session credential becomes sensitive and needs the same logging and caching protections as the original link.

HTTP range responses also require correct status, boundaries and content-range metadata; they are not just slicing a buffer. HTTP range requests.

Treat retries as a product decision

Under the attempt policy, a lost response can consume an allowance without delivering useful bytes. Do not silently increment the counter on any socket error: a recipient may already have received the complete file, and a retry refund can be abused.

If reliability matters more than strict attempts, offer a short-lived transfer session or an authenticated support reset. Use an idempotency key scoped to the verified account and original link when creating that session. A unique database constraint must protect the key; an in-memory map does not coordinate multiple workers.

HEAD requests should be non-consuming. A preview page should not start a download automatically. Cache bypasses can break accounting, so protected responses and grant endpoints need deliberate no-store handling.

Run a reproducible race test

In a disposable database, seed one unexpired link with remaining set to one. Open two independent database connections. Begin a transaction on the first, execute the update and keep it uncommitted. Execute the same update on the second; it should wait. Commit the first. The second should report zero affected rows. Finally query remaining and verify it is zero, never negative.

Repeat with ten parallel requests through the application. Test expiry, revocation, a different account, HEAD, a rejected range and a disconnect after authorization. Record expected consumption for every case. Database tests have not been executed in this tutorial environment; this is an acceptance procedure, not a claimed result.

If both requests succeed, check that they use the same row, database and conditional mutation. If one hangs, inspect transaction lifetime and missing commits. For related one-time semantics see read-once transaction boundaries, or plan a private download application.