Enforcing Per-User Storage Quotas

Track two numbers per user — used_bytes for committed files and reserved_bytes for uploads in flight — and, when issuing an upload URL, atomically reserve the declared size with a single conditional UPDATE … WHERE used_bytes + reserved_bytes + $size <= quota_bytes. Bind that exact size into the presigned URL (signed Content-Length, or a POST policy content-length-range) so the client cannot upload more than it reserved. When the upload completes, move the actual object size from reserved to used; when it is abandoned, a sweeper releases the reservation. Deletions decrement used_bytes in the same transaction that removes the record.

With direct-to-cloud uploads, your server never sees the bytes, so it cannot count them as they arrive. The quota has to be enforced at the only point the server is involved — issuing the URL — and reconciled when storage reports the result. Without reservations, ten parallel uploads each check “is there room?” against the same balance, all pass, and the user ends up far over quota. This page belongs to upload rate limiting and abuse protection in backend validation and cloud storage architecture; it pairs with enforcing upload size limits with S3 POST policies and rate limiting presigned URL issuance.

When to use this approach

  • Plans include a storage allowance (free tier 5 GB, paid 1 TB) that must hold under concurrency.
  • Uploads go directly to object storage with presigned URLs or resumable sessions.
  • You want users to see accurate “space used” figures, including uploads in progress.

Prerequisites

  1. A relational database with atomic conditional updates (PostgreSQL shown).
  2. Upload completion events or a confirm call, as in confirming uploads before committing database records.
  3. Presigned URLs that can bind a size.
  4. A periodic job runner for the sweeper.

The two counters

Quota bar showing used, reserved and free bytes A user with a 5 GB quota has 3.2 GB used by committed files and 1.1 GB reserved by uploads in progress, leaving 0.7 GB free. A new 900 MB upload request is refused because it would exceed the quota, even though committed usage alone would allow it. Free space = quota − used − reserved used 3.2 GB reserved 1.1 0.7 free 0 quota 5 GB new request: 900 MB 3.2 + 1.1 + 0.9 = 5.2 GB > 5 GB → refused before any URL is signed Checking only "used" would have allowed it, and the three in-flight uploads would push the user over.
Reservations make concurrent uploads count against the quota before they finish.

Implementation

CREATE TABLE storage_accounts (
  user_id        uuid PRIMARY KEY,
  quota_bytes    bigint NOT NULL,
  used_bytes     bigint NOT NULL DEFAULT 0 CHECK (used_bytes >= 0),
  reserved_bytes bigint NOT NULL DEFAULT 0 CHECK (reserved_bytes >= 0)
);

CREATE TABLE upload_reservations (
  id          uuid PRIMARY KEY,
  user_id     uuid NOT NULL REFERENCES storage_accounts(user_id),
  object_key  text NOT NULL UNIQUE,
  bytes       bigint NOT NULL CHECK (bytes > 0),
  expires_at  timestamptz NOT NULL,
  settled_at  timestamptz
);
CREATE INDEX ON upload_reservations (expires_at) WHERE settled_at IS NULL;
import { randomUUID } from "node:crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { pool } from "./db.js";
import { s3 } from "./s3.js";

const URL_TTL_S = 15 * 60;

export class QuotaExceeded extends Error {
  constructor(public free: number) { super("storage quota exceeded"); }
}

export async function reserveAndSign(userId: string, size: number, contentType: string) {
  const id = randomUUID();
  const key = `u/${userId}/${id}`;
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const r = await client.query(
      `UPDATE storage_accounts SET reserved_bytes = reserved_bytes + $2
       WHERE user_id = $1 AND used_bytes + reserved_bytes + $2 <= quota_bytes
       RETURNING quota_bytes - used_bytes - reserved_bytes AS free`, [userId, size]);
    if (r.rowCount === 0) {
      const { rows: [a] } = await client.query(
        "SELECT quota_bytes - used_bytes - reserved_bytes AS free FROM storage_accounts WHERE user_id = $1", [userId]);
      throw new QuotaExceeded(Number(a?.free ?? 0));
    }
    await client.query(
      `INSERT INTO upload_reservations (id, user_id, object_key, bytes, expires_at)
       VALUES ($1, $2, $3, $4, now() + make_interval(secs => $5))`, [id, userId, key, size, URL_TTL_S + 3600]);
    await client.query("COMMIT");
  } catch (e) { await client.query("ROLLBACK"); throw e; }
  finally { client.release(); }

  const url = await getSignedUrl(s3, new PutObjectCommand({
    Bucket: process.env.BUCKET!, Key: key, ContentType: contentType, ContentLength: size,
  }), { expiresIn: URL_TTL_S });
  return { reservationId: id, key, url };
}

/** Called from the upload-completed event with the object's real size. */
export async function settle(key: string, actualBytes: number) {
  await pool.query(`
    WITH r AS (
      UPDATE upload_reservations SET settled_at = now()
      WHERE object_key = $1 AND settled_at IS NULL
      RETURNING user_id, bytes)
    UPDATE storage_accounts a
    SET reserved_bytes = a.reserved_bytes - r.bytes,
        used_bytes     = a.used_bytes + $2
    FROM r WHERE a.user_id = r.user_id`, [key, actualBytes]);
}

/** Every few minutes: release reservations whose URLs expired without an upload. */
export async function releaseExpired() {
  await pool.query(`
    WITH r AS (
      UPDATE upload_reservations SET settled_at = now()
      WHERE settled_at IS NULL AND expires_at < now()
      RETURNING user_id, bytes)
    UPDATE storage_accounts a SET reserved_bytes = a.reserved_bytes - s.total
    FROM (SELECT user_id, sum(bytes) AS total FROM r GROUP BY user_id) s
    WHERE a.user_id = s.user_id`);
}

Line-by-line on the decisions that matter

  • One conditional UPDATE. The check and the increment happen in a single statement under the row lock, so two concurrent requests cannot both see enough free space. No SELECT … FOR UPDATE round trip, no application-level lock.
  • Signed Content-Length. The reservation is only meaningful if the client cannot upload more than it declared. Signing the length makes S3 reject a body of any other size; with POST policies, content-length-range with the same maximum does the job.
  • Reservation outlives the URL by an hour. A client can start a PUT just before the URL expires and take a while to finish. Releasing the reservation too early would let the user reserve that space again while the first upload is still arriving.
  • Settling with the actual size. The completion event carries the real object size. Using it for used_bytes keeps accounting exact even if something upstream (a multipart upload with a declared maximum) produced a smaller file.
  • Idempotent settlement. settled_at IS NULL in the WHERE clause means duplicate event deliveries do nothing. Events are at-least-once on every provider.
  • Sweeper groups by user. Releasing many expired reservations in one statement with a per-user sum keeps the job cheap even with thousands of abandoned uploads.

Multipart and resumable uploads

Quota reservation lifecycle for a multipart upload At create, the declared total size is reserved. Each part URL is signed with its exact part length, and the sum of parts cannot exceed the reservation. At complete, the object size is moved from reserved to used. If the upload is aborted or expires, the reservation is released and a lifecycle rule removes stray parts. Reserve the total, sign each part, settle once create reserve declared total sign parts Σ parts ≤ reserved complete reserved → used abort / expire release A lifecycle rule aborting incomplete multipart uploads cleans storage; the sweeper cleans the counters.
Storage cleanup and quota cleanup are separate jobs that must agree.

For multipart uploads, reserve the declared total when creating the upload and track the sum of signed part lengths on the reservation row. Refuse to sign a part that would take the sum past the reservation. Settle on CompleteMultipartUpload using the final object size. Abandoned multipart uploads need both a lifecycle rule (to delete parts, which are billed but invisible) and the sweeper (to release the reservation); set the sweeper’s expiry to match the lifecycle rule’s DaysAfterInitiation.

Resumable protocols like tus or GCS resumable sessions declare the total size up front (Upload-Length, X-Upload-Content-Length), which maps directly to a reservation. Uploads with deferred length — where the client does not know the size in advance — cannot be reserved precisely; either require a size, or reserve a per-upload maximum and settle down to the real size at the end.

Counting deletions, derivatives and versions

Decide what counts. Most products count original uploads only and absorb derivatives (thumbnails, renditions) as a cost of service; counting derivatives surprises users whose usage grows when you add a new rendition. With object versioning, overwritten versions still occupy storage; either exclude them and expire them with lifecycle rules, or count them and show “previous versions” in the usage breakdown. With deduplication, charge each user for their logical files — a shared blob counts fully for each owner — so usage is predictable and deletion always frees what the user expects.

What typically counts towards a user's quota Original uploads count at their logical size. Uploads in progress count as reservations. Derivatives such as thumbnails and renditions usually do not count. Previous versions either do not count and expire automatically, or count and are shown separately. Make the rule simple enough to explain counts originals, at logical size uploads in progress (reserved) usually does not thumbnails and renditions expired previous versions Whatever you choose, the usage screen should add up to the number the quota check uses.
Users trust a quota they can reconcile themselves.

Reconciling the counters

Counters drift: a missed event, a manual deletion in the console, a bug fixed last month. Run a nightly reconciliation that recomputes used_bytes from the files table (or from a storage inventory report for the user’s prefix) and corrects the counter, logging every correction above a small threshold. S3 Inventory or GCS Storage Insights reports list every object with its size daily and are the authoritative source when the database and storage disagree. Alert when corrections are frequent or large; they usually point at a missing event path.

Configuration gotchas

Users report “quota exceeded” with plenty of space shown. The usage screen shows used_bytes only; the check includes reservations. Show both, or display in-progress uploads as part of usage.

Reservations pile up and never release. The sweeper is not running, or expires_at is far in the future. Monitor sum(reserved_bytes) across users; it should stay small relative to used_bytes.

Settlement never happens for some uploads. Completion events are filtered by prefix or suffix and miss some keys. Compare reservation counts with event counts daily.

A downgrade leaves users over quota. Allow used_bytes > quota_bytes (no CHECK against the quota) and block only new reservations; never delete user data automatically on a plan change.

Verification

-- Fire 10 concurrent 900 MB reservations at a 5 GB account with 3.2 GB used; exactly 2 should succeed.
SELECT used_bytes, reserved_bytes, quota_bytes - used_bytes - reserved_bytes AS free
FROM storage_accounts WHERE user_id = :uid;
-- Drift check
SELECT a.user_id, a.used_bytes, coalesce(sum(f.size_bytes), 0) AS actual
FROM storage_accounts a LEFT JOIN files f ON f.owner_id = a.user_id
GROUP BY a.user_id, a.used_bytes HAVING a.used_bytes <> coalesce(sum(f.size_bytes), 0);

Frequently Asked Questions

Can I enforce quotas with storage-side features instead?

Object stores do not offer per-user byte quotas on a shared bucket. Bucket-per-user designs hit account bucket limits quickly. Application-level accounting is the standard approach.

Should quota checks happen on the client too?

Show remaining space in the uploader and warn before selecting files that will not fit — it saves users a failed upload. The server check remains the one that counts.

How do I handle a single upload bigger than the whole quota?

Reject it at reservation time with a message that includes the limit and the file size, and link to the upgrade path if you have one.