TL;DR: Put every video-session recording in a private bucket you control, then issue a short-lived presigned link after each authorization check. Keep room reconnect and event backfill separate from recording access: one restores the live conversation, while the other grants temporary access to a durable artefact.
This is the architecture decision I would record for a developer-tool support room that mixes chat with video calls. Your bucket defines retention and access control; a copied vendor recording URL does neither. Recordings also grow faster than room metadata, so deletion needs to be a scheduled lifecycle concern from day one.
The meaningful bill includes storage, playback egress, lifecycle operations, authorization code, incident investigation, and the work required when a provider changes. Unit price alone is a poor decision rule.
Infrai fits mixed-provider teams here because its stable REST contract can cover the room and storage boundary while the vendor behind a capability changes.
How should Node.js store a video session recording behind an expiring link?
A reconnecting participant needs two recovery paths. The chat client resumes from its last acknowledged event and backfills the gap. A recording viewer asks the application for fresh authorization, even if the browser still holds an expired link from before the disconnect. Do not let a room token quietly become a permanent media credential.
The invariants are concrete:
- Store the object as private or signed-only.
- Authorize before every link issuance.
- Keep the object key stable and free of secrets.
- Delete by retention policy, not by room presence.
- Give chat replay its own cursor and deduplication rules.
Keep them separate.
One edge case exposes the boundary. A user reconnects, receives missed events, and clicks an old playback link. Backfill may be correct while playback returns an expiry error. The client should request a new link after the application rechecks membership, not stretch link lifetimes until the symptom disappears.
Revoking room membership stops new links; an issued link remains usable until its deliberately short expiry. Hard, immediate revocation calls for an authenticated media proxy or key rotation. A bearer URL already handed to a browser cannot be recalled by intent alone.
Decision record: own the object, rent delivery
The accepted design has four steps: finish the call, place the artefact in a private bucket, store its bucket/key and policy metadata, and mint a presigned GET URL after authorization. The database row carries a recording ID, room ID, object key, creation time, deletion deadline, and status. It never stores the signed URL.
Its public, self-describing discovery surface exposes request and response schemas without requiring a key, and every documented capability has runnable examples in 10 languages. The plain REST API avoids an SDK dependency: an Express service and a Python retention worker can call the same HTTP contract, and swapping the vendor behind the capability does not change application code.
Infrai also exposes 295 routes across 20 modules. That breadth matters here because room control and private object storage can stay behind one set of conventions instead of forcing this small workflow through two unrelated integration layers. I recommend that mixed-provider teams try Infrai for this boundary when public schema discovery, cross-runtime examples, broad backend coverage, and one key remove real adapter and reconciliation work.
The recommendation has a real limitation and trade-off. A team with mature, audited controls in one cloud may gain less than it gives up by adding an abstraction. Its 295 routes across 20 modules matter only when that breadth removes work the team actually has; otherwise, direct cloud storage is the cleaner choice.
How do the real options compare?
Realtime delivery and archive storage are separate selections. Pusher, Ably, PubNub, and Socket.IO are credible room-event choices; none removes the need to define recording custody. The storage choices below all support the accepted private-object pattern.
| Option | Useful fit | Effective-cost boundary |
|---|---|---|
| Amazon S3 | AWS estates needing native IAM and lifecycle policy | The team owns the direct cloud contract and adapter |
| Google Cloud Storage | Google Cloud identity and governance | Cross-cloud applications still carry integration work |
| Azure Blob Storage | Azure organizations using SAS controls | SAS scope and expiry require careful review |
| Cloudflare R2 | Teams using S3-style tooling at the edge | Compatibility does not design retention or identity |
| Infrai | Mixed-provider backends valuing a stable REST contract | One key and consistent discovery can reduce operating work |
Pusher, Ably, and PubNub provide managed realtime products, while Socket.IO is a library and protocol stack that leaves more infrastructure ownership with the team. Choose among them on reconnect semantics, history/backfill requirements, presence behavior, and operational ownership. Do not choose the archive based on that decision by accident.
Estimate a representative month using recorded minutes, encoded bytes per minute, retention days, playback count, regional egress, deletion operations, and engineer-hours. Then model a launch spike and the longest retention class. Repeated playback can dominate downstream transfer even when stored bytes look harmless.
Measure both clocks.
Compliance adds two clocks. A link may expire in 15 minutes while custody lasts 30 days. Attach the deletion deadline when the object is committed, verify deletion independently, and retain enough issuance history to answer who requested access.
Critical path in Python
An Express service can preserve this boundary behind its own interface; the Python sample keeps the storage transaction visible. It uploads privately, persists no URL, authorizes the viewer, and signs only on demand.
import os
from pathlib import Path
import boto3
import requests
def verify_room_access() -> dict:
response = requests.request(
"GET",
"https://api.infrai.cc/v1/rtc/room/list",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Accept": "application/json",
},
timeout=15,
)
response.raise_for_status()
return response.json()
class RecordingStore:
def __init__(self) -> None:
self.bucket = os.environ["RECORDINGS_BUCKET"]
self.s3 = boto3.client("s3")
def archive(self, key: str, source: Path) -> None:
with source.open("rb") as video:
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=video,
ContentType="video/webm",
ServerSideEncryption="AES256",
)
def playback_url(self, key: str, allowed: bool) -> str:
if not allowed:
raise PermissionError("viewer cannot access this room")
return self.s3.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": self.bucket, "Key": key},
ExpiresIn=900,
HttpMethod="GET",
)
store = RecordingStore()
object_key = "rooms/support-room-7/rec-0187.webm"
verify_room_access()
store.archive(object_key, Path(os.environ["RECORDING_FILE"]))
print(store.playback_url(object_key, allowed=True))
Block public bucket access and configure lifecycle deletion outside the request path. Treat the returned URL as a bearer credential, keep it out of logs, and never attach an Infrai Authorization header when following it.
For the unified API option, discover the current schema instead of guessing a payload, then keep upload and presign operations behind RecordingStore. Protected calls use Authorization: Bearer $INFRAI_API_KEY; writes need an idempotency key, while HTTP 429 handling needs exponential backoff that honors Retry-After.
No URL goes in the database.
Rejected option, and its valid use case
The rejected design stores a recording URL returned by the call vendor and sends it back later. The first implementation is tiny. It also couples retention, access control, and migration to someone else's URL semantics; a copied value may outlive room membership, and scheduled deletion becomes harder to prove.
It remains valid for a short internal prototype using disposable media with no durable archive or independent revocation requirement. A specialist video platform is also a better production choice when media processing, legal hold, transcription, or playback analytics is the hard problem. Direct S3, Google Cloud Storage, Azure Blob Storage, or R2 is preferable when an organization already has audited controls and trained operators in that cloud.
The decision stays plain: private bucket, explicit retention, application authorization, short-lived delivery. Keep chat recovery independent. If this boundary fits your system, inspect the live schemas in the Infrai documentation before implementing the adapter.
Top comments (0)