DEV Community

Fenil Patel
Fenil Patel

Posted on

How I Built Unlimited Cloud Storage Using Telegram's MTProto API in the Browser (No Backend)

I wanted unlimited cloud storage that:

  • Costs nothing to run
  • Has no server I need to maintain
  • Doesn't require trusting a third party with my files
  • Works from any browser without installing an app

Google Drive gives 15 GB free. Dropbox gives 2 GB. Self-hosted solutions like Nextcloud require a server.

Then I remembered: Telegram has no storage quota. Files you send to Saved Messages or private channels stay there forever. And Telegram has an official API.

So I built *TGDrive *— a Google Drive-style interface that runs entirely in your browser and uses Telegram as the storage backend. No server. No subscription. No storage limit.

🔗 Live: https://fbpatel003.github.io/tgdrive
🔗 GitHub: https://github.com/fbpatel003/tgdrive


Architecture

Your Browser
├── React UI (TGDrive interface)
├── GramJS (Telegram MTProto client — WebSocket)
├── Service Worker (video range request handler)
├── IndexedDB (session + metadata + thumbnail cache)
└── WebSocket ──────────────────► Telegram Servers
                                        └── Your files stored here permanently

# No TGDrive server exists between you and Telegram
Enter fullscreen mode Exit fullscreen mode

The key insight: Telegram's MTProto protocol is a WebSocket-based protocol. GramJS, a JavaScript implementation, can run directly in the browser. Your browser becomes the Telegram client.


Challenge 1: Running GramJS in Vite

GramJS was written for Node.js. It uses Buffer, process, crypto, stream, os and other Node built-ins that don't exist in browsers.

The polyfill setup:

npm install -D vite-plugin-node-polyfills
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts
import { nodePolyfills } from 'vite-plugin-node-polyfills'

export default defineConfig({
  plugins: [
    react(),
    nodePolyfills({
      include: ['buffer', 'process', 'stream', 'util', 'events', 'path', 'crypto'],
      globals: { Buffer: true, global: true, process: true },
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

The os module problem:

GramJS calls os.type() to identify the client device. There's no os module in browsers. The solution: a physical stub file aliased in Vite:

// src/stubs/os.ts
export function type() { return 'Browser' }
export function platform() { return 'browser' }
export function release() { return '0.0.0' }
// ... etc
export default { type, platform, release, ... }
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts
resolve: {
  alias: {
    os: path.resolve(__dirname, 'src/stubs/os.ts'),
  },
},
Enter fullscreen mode Exit fullscreen mode

The socket problem:

GramJS defaults to ConnectionTCPObfuscated which uses Node's net.Socket. In the browser this crashes with net.Socket is not a constructor. The fix: pass PromisedWebSockets explicitly:

import { PromisedWebSockets } from "telegram/extensions/PromisedWebSockets";

const client = new TelegramClient(session, apiId, apiHash, {
  connectionRetries: 5,
  useWSS: true,
  networkSocket: PromisedWebSockets, // ← critical
});
Enter fullscreen mode Exit fullscreen mode

Challenge 2: AUTH_KEY_DUPLICATED on page refresh

This one took the longest to debug.

What happens:

MTProto authenticates via auth keys — cryptographic keys negotiated between client and server. When you refresh the page:

  1. A new TelegramClient is created with the session string from IndexedDB
  2. connect() sometimes renegotiates a new auth key
  3. StringSession.save() just returns a string — it doesn't write anywhere
  4. The new key exists on Telegram's server but the old key is still in IndexedDB
  5. Next refresh: Telegram sees two keys registered → AUTH_KEY_DUPLICATED

The deeper problem:

StringSession only stores ONE auth key (for the main DC). Telegram uses multiple data centres (DCs). When you download a file from DC2, getAuthKey(dc2) returns undefined → GramJS generates a fresh DC2 key → same duplication problem.

The fix:

Subclass StringSession to intercept every key write and persist it to IndexedDB immediately:

class PersistentSession extends StringSession {
  private _dcKeys: Map<number, AuthKey> = new Map();

  override save(): string {
    const str = super.save();
    if (str) {
      // Write main session back to IndexedDB immediately
      db.credentials.get(1).then((creds) => {
        if (creds && creds.sessionString !== str) {
          db.credentials.put({ ...creds, sessionString: str });
        }
      });
    }
    return str;
  }

  override getAuthKey(dcId?: number): AuthKey | undefined {
    if (dcId && dcId !== this.dcId) {
      return this._dcKeys.get(dcId); // Return stored DC key
    }
    return super.getAuthKey(dcId);
  }

  override setAuthKey(authKey?: AuthKey, dcId?: number): undefined {
    if (dcId && dcId !== this.dcId) {
      if (authKey) {
        this._dcKeys.set(dcId, authKey);
        this._persistDcKeys(); // Save to IndexedDB
      }
      return undefined;
    }
    return super.setAuthKey(authKey, dcId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Video streaming without restarts

My first approach: collect chunks from iterDownload, build a Blob URL every second, set it as <video src>.

The problem: Every time you change <video src>, the video element resets and restarts from the beginning. With a new URL every second, the video constantly restarts until the full file is downloaded.

The real solution: Service Worker + Range Requests

The browser's <video> element natively issues HTTP Range requests (Range: bytes=0-65535). A Service Worker can intercept these and respond with the exact bytes requested.

Browser <video> → Range request → Service Worker → GramJS chunk → 206 Response
Enter fullscreen mode Exit fullscreen mode

The flow:

  1. Register a stream with the SW before setting <video src>
  2. Give <video> a URL like /tgdrive-stream/file-id
  3. SW intercepts the range request, waits for data
  4. Main thread streams 512KB chunks from Telegram via iterDownload, posts each to SW
  5. SW responds with exactly the bytes the player asked for
  6. Video starts playing after the first chunk, seeks without re-downloading
// Main thread
postToSW({ type: 'STREAM_REGISTER', streamId, fileSize, mimeType });
onUrl(`/tgdrive-stream/${streamId}`); // Give video element the URL immediately

for await (const chunk of client.iterDownload({ ... })) {
  postToSW({ type: 'STREAM_CHUNK', streamId, chunk: ab });
}
postToSW({ type: 'STREAM_DONE', streamId });
Enter fullscreen mode Exit fullscreen mode
// Service Worker
self.addEventListener('fetch', (event) => {
  if (!url.pathname.startsWith('/tgdrive-stream/')) return;
  event.respondWith(handleRange(event.request, streamId));
});

async function handleRange(request, streamId) {
  const rangeHeader = request.headers.get('Range');
  // Parse range, wait for chunks, respond with 206 Partial Content
}
Enter fullscreen mode Exit fullscreen mode

Challenge 4: File metadata accuracy

When you upload a file to Telegram, it sometimes renames it or strips metadata. A photo becomes photo_123.jpg, a document loses its extension.

The fix: Store metadata as a JSON caption on every uploaded message:

const meta = {
  v: 1,
  name: file.name,
  size: file.size,
  mime: file.type,
  uploadedAt: Date.now(),
};

await client.sendFile(peer, {
  file: uploadedFile,
  caption: JSON.stringify(meta),
  forceDocument: true, // Always upload as document, never as photo
});
Enter fullscreen mode Exit fullscreen mode

On retrieval, parse the caption first. Fall back to Telegram's document attributes for old files:

const meta = parseCaption(msg.message);
if (meta) {
  name = meta.name; size = meta.size; mimeType = meta.mime;
} else {
  // Fallback: read from Telegram document attributes
}
Enter fullscreen mode Exit fullscreen mode

forceDocument: true also prevents auto-download on Telegram mobile — files appear as attachments, not media.


Challenge 5: GitHub Pages SPA routing

React Router + GitHub Pages = 404 on direct URL access or refresh.

The solution involves three parts:

  1. public/404.html — catches 404s, extracts the path, redirects to /?p=/drive/123
  2. index.html script — reads ?p= parameter, restores path with history.replaceState
  3. BrowserRouter basename — set to import.meta.env.BASE_URL so React Router strips the repo name from routes
// App.tsx
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
// ...
<BrowserRouter basename={basename}>
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts
const repoName = process.env.GITHUB_REPOSITORY
  ? `/${process.env.GITHUB_REPOSITORY.split('/')[1]}/`
  : '/'

export default defineConfig({
  base: repoName,
  // ...
})
Enter fullscreen mode Exit fullscreen mode

GITHUB_REPOSITORY is an environment variable automatically set by GitHub Actions to owner/repo-name. No manual config needed.


Result

TGDrive now has:

  • Unlimited storage, 2 GB per file
  • Folder organisation with create/delete
  • Multi-file drag-and-drop upload with real-time progress
  • Lazy-loaded image thumbnails (cached in IndexedDB, evicted after 30 days)
  • Video streaming via Service Worker (no full-file buffering)
  • File search across all folders
  • Persistent login via per-DC auth key persistence

Try it

🔗 Live: https://fbpatel003.github.io/tgdrive
🔗 GitHub: https://github.com/fbpatel003/tgdrive

You'll need API credentials from my.telegram.org — takes 2 minutes to get.


If you found any of these solutions interesting, star the repo — it helps others find it. And if you have questions about any of the implementation details, ask in the comments — I'm happy to go deeper.

Top comments (0)