Supabase (BaaS)

Supabase Storage & Realtime — File Storage và WebSocket

Supabase Storage (S3-compatible, CDN, image transform) và Realtime (Broadcast, Presence, Postgres Changes CDC).

Storage — Lưu trữ file

Tổng quan

Supabase Storage là giải pháp lưu trữ file mạnh mẽ, có khả năng mở rộng với kiểm soát truy cập chi tiếtphân phối tối ưu qua CDN toàn cầu. File được lưu trữ trên hạ tầng S3, metadata được quản lý trong PostgreSQL (bảng storage.objects), và RLS có thể áp dụng trực tiếp cho storage objects -- cùng cơ chế bảo mật với database.

Điểm cốt lõi: Supabase Storage tương thích S3 protocol, metadata được lưu trong Postgres, và file thực tế được lưu trên S3 backend. RLS có thể áp dụng cho storage objects -- cùng cơ chế bảo mật với database.

File Size Limits — Giới hạn dung lượng upload

Supabase Storage hỗ trợ nhiều phương thức upload với giới hạn dung lượng khác nhau:

Phương thức UploadGiới hạnProtocolUse case
Standard Upload50 MBHTTP multipart/form-dataẢnh, documents, file nhỏ
TUS Resumable Upload5 GBTUS protocol (chunked)Video, file nén lớn, backup
Signed Upload5 GBHTTP PUT với signed URLCho phép upload không cần auth
S3-Compatible Upload5 GBS3 multipart uploadTích hợp công cụ S3 hiện có
Tại sao cần TUS? Với file > 50MB, standard upload dễ bị fail do timeout hoặc mạng yếu. TUS chia file thành chunks (~6MB mỗi chunk), upload tuần tự, có thể pause/resume/retry từng chunk. Đặc biệt quan trọng với người dùng ở Việt Nam hoặc khu vực có mạng không ổn định.

Ba loại Bucket

Supabase cung cấp 3 loại bucket, mỗi loại phục vụ mục đích khác nhau:

Loại BucketCông dụngĐặc điểm
File BucketsLưu ảnh, video, document, general-purpose filesDirect URL access với RLS, CDN toàn cầu, image transformation
Analytics BucketsLưu dữ liệu trong Apache Iceberg tablesDành cho data lakes, logs, Supabase Pipelines; query từ Postgres qua foreign tables
Vector BucketsLưu embeddings cho similarity searchDùng HNSW indexing, distance metrics, metadata filtering

Thao tác với Bucket

Các thao tác quản lý bucket cơ bản:

// Tạo bucket mới
const { data, error } = await supabase.storage.createBucket('avatars', {
  public: true,          // bucket public
  fileSizeLimit: 5242880 // giới hạn 5MB
})

// Liệt kê tất cả buckets
const { data: buckets, error } = await supabase.storage.listBuckets()

// Lấy thông tin bucket
const { data, error } = await supabase.storage.getBucket('avatars')

// Cập nhật bucket (ví dụ: đổi giới hạn file size)
const { data, error } = await supabase.storage.updateBucket('avatars', {
  fileSizeLimit: 10485760 // tăng lên 10MB
})

// Xóa bucket (phải empty trước)
const { data, error } = await supabase.storage.deleteBucket('avatars')

// Xóa toàn bộ file trong bucket (empty)
const { data, error } = await supabase.storage.emptyBucket('avatars')

Upload & Download File

Đây là các thao tác cốt lõi khi làm việc với Storage:

// Upload file với upsert
const avatarFile = event.target.files[0]
const { data, error } = await supabase.storage
  .from('avatars')
  .upload(`${userId}/profile.png`, avatarFile, {
    cacheControl: '3600',   // cache 1 giờ trên CDN
    upsert: true,            // ghi đè nếu file đã tồn tại
    contentType: 'image/png' // set content type rõ ràng
  })

// Upload với progress tracking
const { data, error } = await supabase.storage
  .from('videos')
  .upload('tutorial.mp4', file, {
    onProgress: (progress) => {
      console.log(`Uploaded: ${progress.percent}%`)
    }
  })

// Download file
const { data, error } = await supabase.storage
  .from('avatars')
  .download(`${userId}/profile.png`)

// Get public URL (không hết hạn)
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${userId}/profile.png`)
// data.publicUrl -> https://[project].supabase.co/storage/v1/object/public/avatars/...

// List files trong thư mục
const { data, error } = await supabase.storage
  .from('avatars')
  .list(userId, {
    limit: 100,
    offset: 0,
    sortBy: { column: 'created_at', order: 'desc' }
  })

// Di chuyển / đổi tên file
const { data, error } = await supabase.storage
  .from('avatars')
  .move(`${userId}/old.png`, `${userId}/new.png`)

// Copy file
const { data, error } = await supabase.storage
  .from('avatars')
  .copy(`${userId}/profile.png`, `backup/${userId}/profile.png`)

// Xóa file
const { data, error } = await supabase.storage
  .from('avatars')
  .remove([`${userId}/profile.png`, `${userId}/banner.png`])

S3-Compatible API

Supabase Storage tương thích S3 protocol, cho phép dùng bất kỳ S3 client SDK hoặc CLI tool nào để truy cập:

# Dùng AWS CLI với Supabase làm S3 endpoint
aws s3 ls s3://avatars/ --endpoint-url https://[PROJECT_REF].supabase.co/storage/v1/s3

# Dùng MinIO client
mc alias set supabase https://[PROJECT_REF].supabase.co/storage/v1/s3 \
  [ACCESS_KEY] [SECRET_KEY]
mc ls supabase/avatars/

Điều này cho phép tích hợp với các công cụ xử lý media, backup automation, và hệ thống hiện có đã dùng S3.

CDN & Image Transformation

Toàn bộ file được phục vụ qua CDN toàn cầu với 285+ điểm hiện diện. Đặc biệt, Supabase hỗ trợ transform ảnh on-the-fly qua URL parameters:

# URL format đầy đủ cho image transformation

# Base URL pattern:
https://<project>.supabase.co/storage/v1/render/image/public/bucket/avatar.png

# Resize theo width (giữ aspect ratio)
?width=500

# Resize theo height (giữ aspect ratio)
?height=300

# Resize khớp khung với các chế độ
?width=500&height=500&resize=cover       # Cắt để lấp đầy khung (có thể mất phần rìa)
?width=500&height=500&resize=contain     # Giữ trọn ảnh trong khung (có thể có khoảng trống)
?width=500&height=500&resize=fill        # Bóp méo để lấp đầy khung (không giữ aspect ratio)

# Thay đổi chất lượng ảnh (0-100, mặc định 80)
?width=500&quality=85

# Chuyển đổi định dạng ảnh
?width=500&format=webp                   # WebP — nhẹ hơn JPEG ~30%, trình duyệt hiện đại đều hỗ trợ
?width=500&format=avif                   # AVIF — nhẹ hơn WebP ~20%, hỗ trợ HDR
?width=500&format=png                    # PNG — lossless, dùng cho ảnh có transparency
?width=500&format=jpg                    # JPEG — tương thích rộng nhất

# Kết hợp nhiều tham số — ví dụ thực tế:
/storage/v1/render/image/public/bucket/photo.jpg
  ?width=400
  &height=400
  &resize=cover
  &quality=80
  &format=webp

# Kết quả: Ảnh 400x400px, crop center, chất lượng 80%, định dạng WebP
# File size giảm từ ~2MB (ảnh gốc) xuống còn ~15KB
Best practice: Luôn tạo thumbnail qua CDN transform thay vì upload nhiều kích thước. Chỉ cần upload ảnh gốc chất lượng cao, các biến thể được sinh on-demand và cache trên CDN. Điều này tiết kiệm storage và đơn giản hóa logic upload.
Interview point — Image Transformation: CDN transform hoạt động thông qua imgproxy service. Ảnh gốc được lưu một lần, các biến thể (thumbnail, webp, avif) được tạo on-the-fly khi request đầu tiên và cache trên CDN edge. Không tốn thêm storage. Format webp recommended cho web, avif cho ứng dụng modern. resize=cover phổ biến nhất cho avatar/thumbnail.

Signed URLs — Truy cập có thời hạn

Với file private, bạn có thể tạo URL tạm thời cho phép truy cập trong khoảng thời gian giới hạn:

// Tạo signed URL (hết hạn sau 60 giây)
const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contract-2024.pdf', 60)
// data.signedUrl -> URL có ?token=xxx, hết hạn sau 60s

// Tạo signed URL cho upload (cho phép upload mà không cần auth)
const { data, error } = await supabase.storage
  .from('submissions')
  .createSignedUploadUrl(`${taskId}/submission.pdf`)

// Signed URL cho nhiều file cùng lúc
const { data, error } = await supabase.storage
  .from('exports')
  .createSignedUrls(['report-q1.pdf', 'report-q2.pdf'], 300)

Row Level Security cho Storage

RLS có thể áp dụng cho storage objects giống như database tables. Điều này cho phép kiểm soát chính xác ai được xem, upload, hay xóa file nào.

-- Pattern 1: User chỉ truy cập được file trong thư mục của mình
-- File được tổ chức theo cấu trúc: {userId}/filename.png
-- (storage.foldername(name))[1] trả về phần đầu tiên của path = userId

CREATE POLICY "Users can view own avatars"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

CREATE POLICY "Users can upload own avatars"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

CREATE POLICY "Users can update own avatars"
ON storage.objects FOR UPDATE
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

CREATE POLICY "Users can delete own avatars"
ON storage.objects FOR DELETE
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Pattern 2: Public read + authenticated write
CREATE POLICY "Public read access"
ON storage.objects FOR SELECT
USING (bucket_id = 'public-assets');

CREATE POLICY "Authenticated users can upload"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'public-assets'
  AND auth.role() = 'authenticated'
);

-- Pattern 3: Admin-only access (dùng service role)
CREATE POLICY "Admin full access"
ON storage.objects FOR ALL
USING (
  bucket_id = 'admin-files'
  AND auth.jwt()->>'role' = 'admin'  -- Custom claim từ JWT
);

Hàm storage.foldername(name) trả về mảng các phần của path, [1] là thư mục gốc đầu tiên -- đây là convention phổ biến để tổ chức file theo {userId}/....

Interview point — Storage RLS: RLS trên storage.objects hoạt động giống hệt RLS trên database tables. storage.foldername(name) là helper function trả về mảng path segments -- dùng để parse userId từ file path. Luôn tạo policy cho cả 4 operations (SELECT, INSERT, UPDATE, DELETE) để bảo mật toàn diện.

TUS Resumable Uploads

Hỗ trợ upload file lớn với TUS protocol (pause, resume, retry):

// Dùng Uppy với TUS
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'

const uppy = new Uppy()
  .use(Tus, {
    endpoint: `https://[PROJECT_REF].supabase.co/storage/v1/upload/resumable`,
    headers: {
      authorization: `Bearer ${supabaseKey}`,
      'x-upsert': 'true'
    },
    chunkSize: 6 * 1024 * 1024, // 6MB chunks
    retryDelays: [0, 1000, 3000, 5000] // retry delays
  })

TUS đặc biệt hữu ích khi upload video, file nén lớn, hoặc trong môi trường mạng không ổn định.

Multi-Tenancy Patterns

Khi xây dựng ứng dụng multi-tenant (SaaS), bạn có thể tổ chức storage theo nhiều cách:

// Pattern 1: Phân tách theo thư mục (đơn giản nhất)
const path = `${tenantId}/${userId}/${fileName}`

// Pattern 2: Mỗi tenant một bucket riêng
const bucketName = `tenant-${tenantId}`
await supabase.storage.createBucket(bucketName, { public: false })

// Pattern 3: Bucket dùng chung + RLS policy cho tenant
// Policy trong SQL:
// CREATE POLICY "Tenant isolation"
// ON storage.objects FOR SELECT
// USING (
//   bucket_id = 'documents'
//   AND (storage.foldername(name))[1] = (
//     SELECT tenant_id::text FROM profiles WHERE id = auth.uid()
//   )
// );

Pattern 1 phù hợp với hầu hết ứng dụng. Pattern 2 khi cần isolation cao. Pattern 3 linh hoạt nhất nhưng phức tạp hơn về policy.

Pricing Storage

  • Free: 1 GB file storage, 5 GB băng thông
  • Pro: 100 GB file storage + $0.0213/GB vượt, 250 GB băng thông + $0.09/GB vượt
  • Billing dựa trên: Storage (GB stored/tháng) + Egress (GB transferred ra ngoài)

Realtime — WebSocket Subscriptions

Tổng quan

Supabase Realtime cung cấp WebSocket subscriptions phân phối toàn cầu với 3 chế độ hoạt động:

Chế độMô tảUse case điển hình
BroadcastGửi message low-latency giữa các client, ephemeralChat typing indicators, cursor tracking, game events
PresenceTheo dõi và đồng bộ trạng thái người dùngOnline status, active participants, collaborative editing
Postgres ChangesLắng nghe thay đổi database realtime (CDC)Live dashboards, real-time feeds, thông báo
Cơ chế hoạt động: Postgres Changes dùng PostgreSQL logical replication (WAL -- Write-Ahead Log). Khi có thay đổi trong bảng, WAL records được đọc và broadcast qua WebSocket đến subscribed clients. Filter events trên server side giúp giảm băng thông. Có thể kết hợp RLS để đảm bảo user chỉ nhận events từ dữ liệu họ được phép xem.

Channel Lifecycle — Vòng đời của một Channel

Mỗi WebSocket channel trong Supabase Realtime trải qua các trạng thái sau:

subscribe() gọi
     |
     v
  [JOINING] --> Gửi Join request đến Realtime server
     |
     v
  [JOINED]  --> Channel được tạo, sẵn sàng nhận events
     |         (Lúc này mới có thể subscribe Postgres Changes, Broadcast, Presence)
     |
     v
  [SUBSCRIBED] --> Client đã subscribe và đang nhận events
     |             (Presence.track() chỉ hoạt động ở trạng thái này)
     |
     v
  [CLOSED]  --> Channel bị đóng do:
     |         - unsubscribe() được gọi -> chuyển về [CLOSED] sạch
     |         - Lỗi mạng, timeout -> tự động reconnect -> [JOINING]
     |         - Server đóng channel -> tự động reconnect -> [JOINING]
     |
     v
  [CLOSED] cuối cùng -> cleanup resources
// Theo dõi trạng thái channel
const channel = supabase.channel('room-1')

channel.subscribe((status) => {
  switch (status) {
    case 'SUBSCRIBED':
      console.log('Channel ready, có thể track presence và gửi broadcast')
      channel.track({ user: 'Alice', online_at: new Date().toISOString() })
      break
    case 'CHANNEL_ERROR':
      console.error('Channel error, sẽ tự động retry...')
      break
    case 'TIMED_OUT':
      console.warn('Channel timeout, đang reconnect...')
      break
    case 'CLOSED':
      console.log('Channel đóng hoàn toàn')
      break
  }
})
Interview point — Channel lifecycle: Channel tự động reconnect khi mất kết nối. Bạn chỉ cần xử lý SUBSCRIBED để bắt đầu track presence/send broadcast. Khi reconnect thành công, channel tự động re-subscribe tất cả listeners đã đăng ký trước đó. Không cần code reconnect logic thủ công.

Postgres Changes (CDC)

Đây là chế độ mạnh mẽ nhất -- subscribe vào các thay đổi INSERT, UPDATE, DELETE trên bảng cụ thể:

// Tạo channel và subscribe lắng nghe INSERT
const channel = supabase.channel('db-changes')

channel.on('postgres_changes',
  { event: 'INSERT', schema: 'public', table: 'messages' },
  (payload) => {
    console.log('New record:', payload.new)
    console.log('Event type:', payload.eventType)
    // payload.new chứa toàn bộ row mới
  }
).subscribe()

// Lắng nghe UPDATE với filter server-side
channel.on('postgres_changes',
  {
    event: 'UPDATE',
    schema: 'public',
    table: 'messages',
    filter: 'room_id=eq.123' // filter ngay trên server
  },
  (payload) => {
    console.log('Old data:', payload.old)  // dữ liệu cũ
    console.log('New data:', payload.new)  // dữ liệu mới
  }
)

// Lắng nghe DELETE
channel.on('postgres_changes',
  {
    event: 'DELETE',
    schema: 'public',
    table: 'messages',
    filter: 'room_id=eq.123'
  },
  (payload) => {
    console.log('Deleted record id:', payload.old.id)
  }
)

// Lắng nghe tất cả events: dùng '*'
channel.on('postgres_changes',
  { event: '*', schema: 'public', table: 'messages' },
  (payload) => {
    // payload.eventType -> 'INSERT' | 'UPDATE' | 'DELETE'
  }
)

Payload chi tiết cho từng event type:

Eventpayload.newpayload.oldpayload.eventTypeGhi chú
INSERTRow mới (full){} (object rỗng)'INSERT'payload.new chứa tất cả columns của row
UPDATERow sau khi update (full)Row trước khi update (full)'UPDATE'So sánh newold để biết column nào thay đổi
DELETE{} (object rỗng)Row bị xóa'DELETE'Trong RLS mode, payload.old chỉ chứa id (primary key)
* (tất cả)Tùy eventTùy event'INSERT' / 'UPDATE' / 'DELETE'Dùng chung một handler cho mọi thay đổi
Lưu ý về DELETE + RLS: Khi RLS được enable, payload.old trong DELETE event chỉ chứa primary key (id). Điều này là do RLS filter được áp dụng ở database level, và sau khi row bị xóa, hệ thống không thể đọc full data của row đó (vì nó không còn tồn tại để kiểm tra RLS policy).

Server-Side Filtering

Filter được thực thi trên server, giúp giảm đáng kể băng thông:

// Filter đơn
filter: 'room_id=eq.123'

// Filter kết hợp
filter: 'room_id=eq.123 AND user_id=eq.456'

// Filter với nhiều điều kiện
filter: 'status=in.(active,pending)'

// Filter IS NULL
filter: 'deleted_at=is.null'

Không gửi toàn bộ data về client rồi lọc -- server chỉ gửi những events khớp filter.

Enable Realtime cho bảng

Để Postgres Changes hoạt động, cần enable Realtime cho bảng:

-- Enable cho một bảng cụ thể
ALTER PUBLICATION supabase_realtime ADD TABLE messages;

-- Enable cho nhiều bảng cùng lúc
ALTER PUBLICATION supabase_realtime ADD TABLE messages, notifications;

-- Enable cho toàn bộ schema (ít dùng vì gây nhiễu)
ALTER PUBLICATION supabase_realtime ADD TABLE public.*;

-- Kiểm tra bảng nào đã được enable
SELECT * FROM pg_publication_tables
WHERE pubname = 'supabase_realtime';

-- Kiểm tra trạng thái publication
SELECT * FROM pg_publication
WHERE pubname = 'supabase_realtime';

-- Xóa bảng khỏi publication (disable realtime)
ALTER PUBLICATION supabase_realtime DROP TABLE old_table;
Interview point — Realtime Extension: Supabase Realtime dùng PostgreSQL publication supabase_realtime để xác định bảng nào được replicate qua WAL. Chỉ các bảng trong publication này mới có thể subscribe Postgres Changes. Việc này giúp kiểm soát chính xác bảng nào được expose qua realtime, tránh lộ dữ liệu nhạy cảm.

Broadcast

Broadcast gửi message tạm thời (ephemeral) giữa các client. Message không được lưu trữ -- nếu client offline, họ sẽ bỏ lỡ message. Phù hợp cho dữ liệu không cần persistence:

const channel = supabase.channel('room-1', {
  config: {
    broadcast: { self: false } // không nhận lại message của chính mình
  }
})

// Gửi message
channel.send({
  type: 'broadcast',
  event: 'cursor-move',
  payload: { x: 100, y: 200, userId: 'user-123' }
})

// Gửi typing indicator
channel.send({
  type: 'broadcast',
  event: 'typing',
  payload: { userId: 'user-123', isTyping: true }
})

// Nhận message
channel.on('broadcast', { event: 'cursor-move' }, (payload) => {
  console.log('Cursor moved:', payload)
})

channel.on('broadcast', { event: 'typing' }, (payload) => {
  showTypingIndicator(payload.userId, payload.isTyping)
})

channel.subscribe()

Presence

Presence tự động theo dõi và đồng bộ trạng thái người dùng. Khi một user disconnect (đóng tab, mất mạng), Presence tự động timeout và xóa user đó khỏi danh sách:

const channel = supabase.channel('room-1')

// Lắng nghe thay đổi presence
channel.on('presence', { event: 'sync' }, () => {
  const state = channel.presenceState()
  console.log('Online users:', Object.keys(state).length)
  // state có dạng:
  // {
  //   'user-123': [{ online_at: '2024-01-01T00:00:00Z', name: 'Vinh' }],
  //   'user-456': [{ online_at: '2024-01-01T00:00:01Z', name: 'Alice' }]
  // }
})

channel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
  console.log('User joined:', key, newPresences)
  // key: 'user-123'
  // newPresences: [{ online_at: '...', name: 'Vinh' }]
})

channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
  console.log('User left:', key)
  // key: 'user-123'
  // leftPresences: [{ online_at: '...', name: 'Vinh' }]
})

// Subscribe và track presence
channel.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    // Bắt đầu track — server sẽ sync state cho tất cả clients khác
    await channel.track({
      user_id: 'user-123',
      name: 'Vinh',
      online_at: new Date().toISOString(),
      cursor: { x: 0, y: 0 }
    })
  }
})

// Cập nhật presence state (ví dụ: khi di chuyển cursor)
document.addEventListener('mousemove', (e) => {
  channel.track({
    user_id: 'user-123',
    name: 'Vinh',
    online_at: new Date().toISOString(),
    cursor: { x: e.clientX, y: e.clientY }
  })
})

Presence API đầy đủ:

MethodMô tả
channel.presenceState()Trả về toàn bộ state hiện tại của tất cả users trong channel
channel.track(data)Đăng ký / cập nhật presence của user hiện tại
channel.untrack()Hủy presence tracking (user được coi là offline)
on('presence', { event: 'sync' })Fire khi state được đồng bộ đầy đủ (initial + định kỳ)
on('presence', { event: 'join' })Fire khi có user mới tham gia channel
on('presence', { event: 'leave' })Fire khi user rời channel (timeout hoặc untrack)
Interview point — Presence: Presence dùng heartbeat mechanism: mỗi client gửi heartbeat định kỳ (mặc định 30s). Nếu server không nhận được heartbeat trong timeout window (mặc định 60s), user được coi là offline và tự động bị xóa. sync event gửi toàn bộ state (không phải delta), đảm bảo client luôn có state chính xác ngay cả khi bỏ lỡ events trước đó. Không cần gửi message thủ công.

Authorization qua RLS

Realtime tôn trọng RLS policies -- user chỉ nhận được events từ dữ liệu họ được phép xem:

-- Nếu bảng có RLS policy:
CREATE POLICY "Users can only see own messages"
ON messages FOR SELECT
USING (auth.uid() = user_id);

-- Khi subscribe Postgres Changes, user chỉ nhận events
-- từ messages của chính họ (tự động lọc bởi RLS)

Điều này đảm bảo bảo mật ngay cả với realtime subscriptions.

Rate Limiting & Limits

Supabase Realtime áp dụng giới hạn theo plan để đảm bảo chất lượng dịch vụ:

PlanConcurrent ClientsMessages / Client / GiâyBroadcast Msg / GiâyPresence Track / Giây
Free2002 msg/s5 msg/s5 track/s
Pro5005 msg/s10 msg/s10 track/s
Team1,00010 msg/s20 msg/s20 track/s
EnterpriseCustomCustomCustomCustom

Chi tiết từng loại rate limit:

Loại LimitÁp dụng choKhi vượt quáCách tối ưu
Concurrent ClientsSố WebSocket connections đồng thờiConnection mới bị từ chốiDùng filter server-side để giảm unnecessary subscriptions
Messages/Client/SecondTổng số message gửi qua WebSocket từ 1 client (broadcast + presence track)Message bị throttle/dropBatch updates, debounce cursor tracking
Broadcast Msg/SecondTổng số broadcast messages trong channelMessage bị dropGửi ít thường xuyên hơn, gom nhóm events
Presence Track/SecondSố lần gọi track()Track bị bỏ quaChỉ track khi state thực sự thay đổi
Lưu ý quan trọng: Cần tính toán kỹ số lượng subscriptions và broadcast frequency. Mỗi WebSocket connection đều có cost. Với ứng dụng có nhiều user, hãy dùng filter server-side để giảm số lượng events được gửi đến client.
Interview point — Rate Limiting:Concurrent Clients là giới hạn WebSocket connections đồng thời, không phải số users. Mỗi tab browser = 1 connection. Messages/Client/Second áp dụng cho tổng broadcast + presence track — nếu cần gửi nhiều hơn, xem xét batch updates hoặc nâng cấp plan.

So sánh 3 chế độ

Tiêu chíPostgres ChangesBroadcastPresence
Dữ liệuPersistent (từ DB)Ephemeral (tạm thời)Stateful (đồng bộ)
PersistenceLưu trong PostgresKhông lưuKhông lưu, nhưng sync
Client offlineNhận lại khi reconnectBỏ lỡ messageTự động leave/join
FilterServer-side (WAL)Client phải tự filterKhông cần
Use caseLive feed, dashboardChat typing, game eventsOnline status, collab

Bài tập

Bài tập 1: Avatar Upload với Preview và Progress

Yêu cầu:

  • Cho phép người dùng chọn ảnh từ máy tính
  • Hiển thị preview ảnh trước khi upload
  • Hiển thị thanh tiến trình upload (progress bar)
  • Upload lên Supabase Storage vào thư mục {userId}/avatar.png
  • Sau khi upload, hiển thị ảnh từ CDN với image transformation (resize + crop)

Gợi ý triển khai:

<script setup>
const supabase = useSupabaseClient()
const user = useSupabaseUser()

const file = ref(null)
const previewUrl = ref(null)
const progress = ref(0)
const uploadedUrl = ref(null)

function onFileChange(event) {
  file.value = event.target.files[0]
  previewUrl.value = URL.createObjectURL(file.value)
}

async function uploadAvatar() {
  if (!file.value) return

  const { data, error } = await supabase.storage
    .from('avatars')
    .upload(`${user.value.id}/avatar.png`, file.value, {
      upsert: true,
      contentType: file.value.type
    })

  if (error) throw error

  // URL ảnh đã resize qua CDN
  uploadedUrl.value = supabase.storage
    .from('avatars')
    .getPublicUrl(`${user.value.id}/avatar.png`).data.publicUrl
    + '?width=200&height=200&resize=cover'
}
</script>

Yêu cầu:

  • Hiển thị danh sách ảnh (grid layout) từ một bucket
  • Mỗi ảnh hiển thị thumbnail 300x300 qua CDN transform
  • Khi click vào ảnh, hiển thị ảnh full-size trong modal/lightbox
  • Hỗ trợ lazy loading cho thumbnails
  • Có nút tải ảnh với các kích thước khác nhau (origin, 800px, 400px)

Bài tập 3: Real-Time Chat với Message Subscription

Yêu cầu:

  • Tạo một chat room đơn giản với danh sách messages
  • Subscribe Postgres Changes để nhận messages mới real-time
  • Tự động scroll xuống khi có message mới
  • Hiển thị thời gian gửi, tên người gửi
  • Xử lý optimistic UI khi gửi message

Gợi ý triển khai:

// Subscribe messages real-time
const channel = supabase.channel('chat-room')

channel.on('postgres_changes', {
  event: 'INSERT',
  schema: 'public',
  table: 'messages',
  filter: `room_id=eq.${roomId}`
}, (payload) => {
  messages.value.push(payload.new)
  nextTick(() => scrollToBottom())
}).subscribe()

// Gửi message mới
async function sendMessage(content) {
  const { data, error } = await supabase
    .from('messages')
    .insert({
      room_id: roomId,
      user_id: user.value.id,
      content: content
    })
    .select()
    .single()
}

Bài tập 4: Collaborative Cursor Tracking với Presence

Yêu cầu:

  • Sử dụng Presence để theo dõi vị trí chuột của tất cả users trong cùng channel
  • Hiển thị cursor của các users khác với màu sắc khác nhau
  • Hiển thị danh sách users đang online
  • Khi user thoát, tự động xóa cursor của họ
  • Hiển thị typing indicator khi user đang gõ phím (dùng Broadcast)

© 2026 .NET Fresher Guide. All rights reserved.

Về Trang Web

Hướng dẫn toàn diện để chuẩn bị phỏng vấn vị trí .NET Fresher với nội dung từ lý thuyết đến thực hành.