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
- A relational database with atomic conditional updates (PostgreSQL shown).
- Upload completion events or a confirm call, as in confirming uploads before committing database records.
- Presigned URLs that can bind a size.
- A periodic job runner for the sweeper.
The two counters
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. NoSELECT … FOR UPDATEround 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-rangewith 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_byteskeeps accounting exact even if something upstream (a multipart upload with a declared maximum) produced a smaller file. - Idempotent settlement.
settled_at IS NULLin theWHEREclause 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
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.
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.